aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore7
-rw-r--r--.travis.yml3
-rw-r--r--README.markdown19
-rwxr-xr-xbin/screenplain3
-rwxr-xr-xbin/test3
-rw-r--r--requirements.txt2
-rw-r--r--screenplain/export/html.py19
-rw-r--r--screenplain/export/pdf.py277
-rw-r--r--screenplain/main.py77
-rw-r--r--screenplain/parsers/fountain.py32
-rw-r--r--screenplain/richstring.py20
-rw-r--r--screenplain/types.py53
-rwxr-xr-xsetup.py26
-rw-r--r--tests/files/dual-dialogue.fountain.fdx21
-rw-r--r--tests/files/indentation.fountain11
-rw-r--r--tests/files/indentation.fountain.fdx24
-rw-r--r--tests/files/indentation.fountain.html6
-rw-r--r--tests/files/utf-8-bom.fountain18
-rw-r--r--tests/files/utf-8-bom.fountain.fdx43
-rw-r--r--tests/files/utf-8-bom.fountain.html6
-rw-r--r--tests/fountain_test.py127
21 files changed, 690 insertions, 107 deletions
diff --git a/.gitignore b/.gitignore
index 52e4e61..3b4120c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,9 @@
*.pyc
*.pyo
+
+# Generated by `pip install -e .`
+*.egg-info
+
+# Generated by setuptools
+MANIFEST
+dist/
diff --git a/.travis.yml b/.travis.yml
index e3d0c33..2ece8ef 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,5 +1,6 @@
language: python
python:
+ - "2.7"
- "3.3"
-install: pip install -r requirements-py3.txt
+install: pip install -r requirements.txt
script: bin/test
diff --git a/README.markdown b/README.markdown
index 01c814b..db5a2f5 100644
--- a/README.markdown
+++ b/README.markdown
@@ -33,6 +33,15 @@ the master branch may not always work. I'm currently working on supporting
the whole [Fountain](http://fountain.io) specification. (Fountain
was previously known as "Screenplay Markdown" or "SPMD.")
+Installing
+==========
+
+ pip install screenplain
+
+To enable PDF output, install with the PDF extra (installs ReportLab):
+
+ pip install 'screenplain[PDF]'
+
Credits
=======
@@ -60,8 +69,18 @@ Set up environment using virtualenvwrapper:
mkvirtualenv --no-site-packages screenplain
pip install -r requirements.txt
+<<<<<<< HEAD
For developing for Python 3, instead use:
mkvirtualenv --no-site-packages --python=$(which python3) screenplain-py3
pip install -r requirements-py3.txt
+=======
+ pip install -e .
+
+After this, the `screenplain` command will use the working copy of your code.
+
+To run unit tests and style checks, run:
+
+ bin/test
+>>>>>>> master
diff --git a/bin/screenplain b/bin/screenplain
index ad6a673..e28cb73 100755
--- a/bin/screenplain
+++ b/bin/screenplain
@@ -1,9 +1,6 @@
#!/usr/bin/env python
import sys
-from os.path import dirname, join, abspath, pardir
if __name__ == '__main__':
- p = abspath(join(dirname(__file__), pardir))
- sys.path.append(p)
from screenplain.main import main
main(sys.argv[1:])
diff --git a/bin/test b/bin/test
index da49a7e..0b7ce83 100755
--- a/bin/test
+++ b/bin/test
@@ -1,2 +1,3 @@
#!/bin/bash
-python test.py && pep8 screenplain tests
+nosetests --nocapture --with-doctest --doctest-tests -I ^test.py $* && \
+ pep8 --ignore=E402 screenplain tests
diff --git a/requirements.txt b/requirements.txt
index 08a90f9..50f908b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-#reportlab
+reportlab
unittest2
nose
pep8
diff --git a/screenplain/export/html.py b/screenplain/export/html.py
index 6f1d11d..81c0f8b 100644
--- a/screenplain/export/html.py
+++ b/screenplain/export/html.py
@@ -3,9 +3,6 @@
# http://www.opensource.org/licenses/mit-license.php
from __future__ import with_statement
-import sys
-import re
-import cgi
import os
import os.path
@@ -63,7 +60,8 @@ def to_html(text):
html = text.to_html()
if html == '':
return '&nbsp;'
- return re.sub(' ', '&nbsp; ', html)
+ else:
+ return html
class Formatter(object):
@@ -169,12 +167,11 @@ class Formatter(object):
def _read_file(filename):
- path = os.path.join(os.path.dirname(__file__), filename)
with open(path) as stream:
return stream.read()
-def convert(screenplay, out, bare=False):
+def convert(screenplay, out, css_file=None, bare=False):
"""Convert the screenplay into HTML, written to the file-like object `out`.
The output will be a complete HTML document unless `bare` is true.
@@ -183,15 +180,19 @@ def convert(screenplay, out, bare=False):
if bare:
convert_bare(screenplay, out)
else:
- convert_full(screenplay, out)
+ convert_full(
+ screenplay, out,
+ css_file or os.path.join(os.path.dirname(__file__), 'default.css')
+ )
-def convert_full(screenplay, out):
+def convert_full(screenplay, out, css_file):
"""Convert the screenplay into a complete HTML document,
written to the file-like object `out`.
"""
- css = _read_file('default.css')
+ with open(css_file, 'r') as stream:
+ css = stream.read()
out.write(
'<!DOCTYPE html>\n'
'<html>'
diff --git a/screenplain/export/pdf.py b/screenplain/export/pdf.py
new file mode 100644
index 0000000..6955616
--- /dev/null
+++ b/screenplain/export/pdf.py
@@ -0,0 +1,277 @@
+# Copyright (c) 2014 Martin Vilcans
+# Licensed under the MIT license:
+# http://www.opensource.org/licenses/mit-license.php
+
+import sys
+
+try:
+ import reportlab
+except ImportError:
+ sys.stderr.write('ERROR: ReportLab is required for PDF output\n')
+ raise
+del reportlab
+
+from reportlab.lib import pagesizes
+from reportlab.platypus import (
+ BaseDocTemplate,
+ Paragraph,
+ Frame,
+ PageTemplate,
+ Spacer,
+)
+from reportlab import platypus
+from reportlab.lib.units import inch
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.enums import TA_CENTER, TA_RIGHT
+
+from screenplain.types import (
+ Action, Dialog, DualDialog, Transition, Slug
+)
+from screenplain import types
+
+font_size = 12
+line_height = 12
+lines_per_page = 55
+characters_per_line = 61
+character_width = 1.0 / 10 * inch # Courier pitch is 10 chars/inch
+frame_height = line_height * lines_per_page
+frame_width = characters_per_line * character_width
+
+page_width, page_height = pagesizes.letter
+left_margin = 1.5 * inch
+right_margin = page_width - left_margin - frame_width
+top_margin = 1 * inch
+bottom_margin = page_height - top_margin - frame_height
+
+
+default_style = ParagraphStyle(
+ 'default',
+ fontName='Courier',
+ fontSize=font_size,
+ leading=line_height,
+ spaceBefore=0,
+ spaceAfter=0,
+ leftIndent=0,
+ rightIndent=0,
+)
+centered_style = ParagraphStyle(
+ 'default-centered', default_style,
+ alignment=TA_CENTER,
+)
+
+# Screenplay styles
+character_style = ParagraphStyle(
+ 'character', default_style,
+ spaceBefore=line_height,
+ leftIndent=19 * character_width,
+ keepWithNext=1,
+)
+dialog_style = ParagraphStyle(
+ 'dialog', default_style,
+ leftIndent=9 * character_width,
+ rightIndent=frame_width - (45 * character_width),
+)
+parenthentical_style = ParagraphStyle(
+ 'parenthentical', default_style,
+ leftIndent=13 * character_width,
+ keepWithNext=1,
+)
+action_style = ParagraphStyle(
+ 'action', default_style,
+ spaceBefore=line_height,
+)
+centered_action_style = ParagraphStyle(
+ 'centered-action', action_style,
+ alignment=TA_CENTER,
+)
+slug_style = ParagraphStyle(
+ 'slug', default_style,
+ spaceBefore=line_height,
+ spaceAfter=line_height,
+ keepWithNext=1,
+)
+transition_style = ParagraphStyle(
+ 'transition', default_style,
+ spaceBefore=line_height,
+ spaceAfter=line_height,
+ alignment=TA_RIGHT,
+)
+
+# Title page styles
+title_style = ParagraphStyle(
+ 'title', default_style,
+ fontSize=24, leading=36,
+ alignment=TA_CENTER,
+)
+contact_style = ParagraphStyle(
+ 'contact', default_style,
+ leftIndent=3.9 * inch,
+ rightIndent=0,
+)
+
+
+class DocTemplate(BaseDocTemplate):
+ def __init__(self, *args, **kwargs):
+ self.has_title_page = kwargs.pop('has_title_page', False)
+ frame = Frame(
+ left_margin, bottom_margin, frame_width, frame_height,
+ id='normal',
+ leftPadding=0, topPadding=0, rightPadding=0, bottomPadding=0
+ )
+ pageTemplates = [
+ PageTemplate(id='standard', frames=[frame])
+ ]
+ BaseDocTemplate.__init__(
+ self, pageTemplates=pageTemplates, *args, **kwargs
+ )
+
+ def handle_pageBegin(self):
+ self.canv.setFont('Courier', font_size, leading=line_height)
+ if self.has_title_page:
+ page = self.page # self.page is 0 on first page
+ else:
+ page = self.page + 1
+ if page >= 2:
+ self.canv.drawRightString(
+ left_margin + frame_width,
+ page_height - 42,
+ '%s.' % page
+ )
+ self._handle_pageBegin()
+
+
+def add_paragraph(story, para, style):
+ story.append(Paragraph(
+ '<br/>'.join(line.to_html() for line in para.lines),
+ style
+ ))
+
+
+def add_slug(story, para, style, is_strong):
+ for line in para.lines:
+ if is_strong:
+ html = '<b><u>' + line.to_html() + '</u></b>'
+ else:
+ html = line.to_html()
+ story.append(Paragraph(html, style))
+
+
+def add_dialog(story, dialog):
+ story.append(Paragraph(dialog.character.to_html(), character_style))
+ for parenthetical, line in dialog.blocks:
+ if parenthetical:
+ story.append(Paragraph(line.to_html(), parenthentical_style))
+ else:
+ story.append(Paragraph(line.to_html(), dialog_style))
+
+
+def add_dual_dialog(story, dual):
+ # TODO: format dual dialog
+ add_dialog(story, dual.left)
+ add_dialog(story, dual.right)
+
+
+def get_title_page_story(screenplay):
+ """Get Platypus flowables for the title page
+
+ """
+ # From Fountain spec:
+ # The recommendation is that Title, Credit, Author (or Authors, either
+ # is a valid key syntax), and Source will be centered on the page in
+ # formatted output. Contact and Draft date would be placed at the lower
+ # left.
+
+ def add_lines(story, attribute, style, space_before=0):
+ lines = screenplay.get_rich_attribute(attribute)
+ if not lines:
+ return 0
+
+ if space_before:
+ story.append(Spacer(frame_width, space_before))
+
+ total_height = 0
+ for line in lines:
+ html = line.to_html()
+ para = Paragraph(html, style)
+ width, height = para.wrap(frame_width, frame_height)
+ story.append(para)
+ total_height += height
+ return space_before + total_height
+
+ title_story = []
+ title_height = sum((
+ add_lines(title_story, 'Title', title_style),
+ add_lines(
+ title_story, 'Credit', centered_style, space_before=line_height
+ ),
+ add_lines(title_story, 'Author', centered_style),
+ add_lines(title_story, 'Authors', centered_style),
+ add_lines(title_story, 'Source', centered_style),
+ ))
+
+ lower_story = []
+ lower_height = sum((
+ add_lines(lower_story, 'Draft date', default_style),
+ add_lines(
+ lower_story, 'Contact', contact_style, space_before=line_height
+ ),
+ add_lines(
+ lower_story, 'Copyright', centered_style, space_before=line_height
+ ),
+ ))
+
+ if not title_story and not lower_story:
+ return []
+
+ story = []
+ top_space = min(
+ frame_height / 3.0,
+ frame_height - lower_height - title_height
+ )
+ if top_space > 0:
+ story.append(Spacer(frame_width, top_space))
+ story += title_story
+ # The minus 6 adds some room for rounding errors and whatnot
+ middle_space = frame_height - top_space - title_height - lower_height - 6
+ if middle_space > 0:
+ story.append(Spacer(frame_width, middle_space))
+ story += lower_story
+
+ story.append(platypus.PageBreak())
+ return story
+
+
+def to_pdf(
+ screenplay, output_filename,
+ template_constructor=DocTemplate,
+ is_strong=False,
+):
+ story = get_title_page_story(screenplay)
+ has_title_page = bool(story)
+
+ for para in screenplay:
+ if isinstance(para, Dialog):
+ add_dialog(story, para)
+ elif isinstance(para, DualDialog):
+ add_dual_dialog(story, para)
+ elif isinstance(para, Action):
+ add_paragraph(
+ story, para,
+ centered_action_style if para.centered else action_style
+ )
+ elif isinstance(para, Slug):
+ add_slug(story, para, slug_style, is_strong)
+ elif isinstance(para, Transition):
+ add_paragraph(story, para, transition_style)
+ elif isinstance(para, types.PageBreak):
+ story.append(platypus.PageBreak())
+ else:
+ # Ignore unknown types
+ pass
+
+ doc = template_constructor(
+ output_filename,
+ pagesize=(page_width, page_height),
+ has_title_page=has_title_page
+ )
+ doc.build(story)
diff --git a/screenplain/main.py b/screenplain/main.py
index 6def5af..770427c 100644
--- a/screenplain/main.py
+++ b/screenplain/main.py
@@ -4,7 +4,6 @@
# Licensed under the MIT license:
# http://www.opensource.org/licenses/mit-license.php
-import fileinput
import sys
import codecs
from optparse import OptionParser
@@ -12,7 +11,7 @@ from optparse import OptionParser
from screenplain.parsers import fountain
output_formats = (
- 'fdx', 'html'
+ 'fdx', 'html', 'pdf'
)
usage = """Usage: %prog [options] [input-file [output-file]]
@@ -50,6 +49,23 @@ def main(args):
'not a complete HTML document.'
)
)
+ parser.add_option(
+ '--css',
+ metavar='FILE',
+ help=(
+ 'For HTML output, inline the given CSS file in the HTML document '
+ 'instead of the default.'
+ )
+ )
+ parser.add_option(
+ '--strong',
+ action='store_true',
+ dest='strong',
+ help=(
+ 'For PDF output, scene headings will appear '
+ 'Bold and Underlined.'
+ )
+ )
options, args = parser.parse_args(args)
if len(args) >= 3:
parser.error('Too many arguments')
@@ -62,6 +78,8 @@ def main(args):
format = 'fdx'
elif output_file.endswith('.html'):
format = 'html'
+ elif output_file.endswith('.pdf'):
+ format = 'pdf'
else:
invalid_format(
parser,
@@ -74,35 +92,46 @@ def main(args):
)
if input_file:
- input = codecs.open(input_file, 'r', 'utf-8')
+ input = codecs.open(input_file, 'r', 'utf-8-sig')
else:
input = codecs.getreader('utf-8')(sys.stdin)
screenplay = fountain.parse(input)
if format == 'pdf':
- from screenplain.export.pdf import to_pdf
- if not output_file:
- sys.stderr.write("Can't write PDF to standard output")
- sys.exit(2)
- to_pdf(screenplay, output_file)
+ output_encoding = None
else:
- if output_file:
- output = codecs.open(output_file, 'w', 'utf-8')
+ output_encoding = 'utf-8'
+
+ if output_file:
+ if output_encoding:
+ output = codecs.open(output_file, 'w', output_encoding)
else:
- output = codecs.getwriter('utf-8')(sys.stdout)
- try:
- if format == 'text':
- from screenplain.export.text import to_text
- to_text(screenplay, output)
- elif format == 'fdx':
- from screenplain.export.fdx import to_fdx
- to_fdx(screenplay, output)
- elif format == 'html':
- from screenplain.export.html import convert
- convert(screenplay, output, bare=options.bare)
- finally:
- if output_file:
- output.close()
+ output = open(output_file, 'wb')
+ else:
+ if output_encoding:
+ output = codecs.getwriter(output_encoding)(sys.stdout)
+ else:
+ output = sys.stdout
+
+ try:
+ if format == 'text':
+ from screenplain.export.text import to_text
+ to_text(screenplay, output)
+ elif format == 'fdx':
+ from screenplain.export.fdx import to_fdx
+ to_fdx(screenplay, output)
+ elif format == 'html':
+ from screenplain.export.html import convert
+ convert(
+ screenplay, output,
+ css_file=options.css, bare=options.bare
+ )
+ elif format == 'pdf':
+ from screenplain.export.pdf import to_pdf
+ to_pdf(screenplay, output, is_strong=options.strong)
+ finally:
+ if output_file:
+ output.close()
if __name__ == '__main__':
main(sys.argv[1:])
diff --git a/screenplain/parsers/fountain.py b/screenplain/parsers/fountain.py
index 19deda3..5434c1a 100644
--- a/screenplain/parsers/fountain.py
+++ b/screenplain/parsers/fountain.py
@@ -8,7 +8,8 @@ import re
from six import next
from screenplain.types import (
- Slug, Action, Dialog, DualDialog, Transition, Section, PageBreak
+ Slug, Action, Dialog, DualDialog, Transition, Section, PageBreak,
+ Screenplay
)
from screenplain.richstring import parse_emphasis, plain
@@ -128,7 +129,11 @@ class InputParagraph(object):
return False
character = self.lines[0]
- if not character.isupper() or character.endswith(TWOSPACE):
+ if character.endswith(TWOSPACE):
+ return False
+ if character.startswith('@') and len(character) >= 2:
+ character = character[1:]
+ elif not character.isupper():
return False
if paragraphs and isinstance(paragraphs[-1], Dialog):
@@ -206,6 +211,11 @@ def _is_blank(line):
def parse(stream):
+ """Parses Fountain source.
+
+ Returns a Screenplay object.
+
+ """
content = stream.read()
content = boneyard_re.sub('', content)
lines = linebreak_re.split(content)
@@ -214,8 +224,11 @@ def parse(stream):
def parse_lines(source):
- """Reads raw text input and generates paragraph objects."""
+ """Reads raw text input and generates paragraph objects.
+ Returns a Screenplay object.
+
+ """
source = (_preprocess_line(line) for line in source)
title_page_lines = list(takewhile(lambda line: line != '', source))
@@ -224,12 +237,14 @@ def parse_lines(source):
if title_page:
# The first lines were a title page.
# Parse the rest of the source as screenplay body.
- # TODO: Create a title page from the data in title_page
- return parse_body(source)
+ return Screenplay(title_page, parse_body(source))
else:
# The first lines were not a title page.
# Parse them as part of the screenplay body.
- return parse_body(itertools.chain(title_page_lines, [''], source))
+ return Screenplay(
+ {},
+ parse_body(itertools.chain(title_page_lines, [''], source))
+ )
def parse_body(source):
@@ -245,7 +260,12 @@ def parse_body(source):
def parse_title_page(lines):
+ """Parse the title page.
+ Spec: http://fountain.io/syntax#section-titlepage
+ Returns None if the document does not have a title page section,
+ otherwise a dictionary with the data.
+ """
result = {}
it = iter(lines)
diff --git a/screenplain/richstring.py b/screenplain/richstring.py
index 2821fcc..ad667d1 100644
--- a/screenplain/richstring.py
+++ b/screenplain/richstring.py
@@ -59,7 +59,11 @@ class RichString(object):
return self.segments[-1].text.endswith(string)
def to_html(self):
- return ''.join(seg.to_html() for seg in self.segments)
+ html = ''.join(seg.to_html() for seg in self.segments)
+ if html.startswith(' '):
+ return '&nbsp;' + html[1:]
+ else:
+ return html
def __eq__(self, other):
return (
@@ -126,7 +130,11 @@ class Segment(object):
ordered_styles = self.get_ordered_styles()
return (
''.join(style.start_html for style in ordered_styles) +
- _escape(self.text) +
+ re.sub(
+ ' +', # at least two spaces
+ lambda m: '&nbsp;' * (len(m.group(0)) - 1) + ' ',
+ _escape(self.text),
+ ) +
''.join(style.end_html for style in reversed(ordered_styles))
)
@@ -209,7 +217,7 @@ class _CreateStyledString(object):
with a single segment with a specified style.
"""
def __init__(self, styles):
- self.styles = styles
+ self.styles = set(styles)
def __call__(self, text):
return RichString(Segment(text, self.styles))
@@ -217,9 +225,9 @@ class _CreateStyledString(object):
def __add__(self, other):
return _CreateStyledString(self.styles.union(other.styles))
-plain = _CreateStyledString(set())
-bold = _CreateStyledString(set((Bold,)))
-italic = _CreateStyledString(set((Italic,)))
+plain = _CreateStyledString(())
+bold = _CreateStyledString((Bold,))
+italic = _CreateStyledString((Italic,))
underline = _CreateStyledString((Underline,))
empty_string = RichString()
diff --git a/screenplain/types.py b/screenplain/types.py
index f8454ef..0325148 100644
--- a/screenplain/types.py
+++ b/screenplain/types.py
@@ -2,6 +2,50 @@
# Licensed under the MIT license:
# http://www.opensource.org/licenses/mit-license.php
+from screenplain.richstring import parse_emphasis
+
+
+class Screenplay(object):
+ def __init__(self, title_page=None, paragraphs=None):
+ """
+ Create a Screenplay object.
+
+ `title_page` is a dictionary mapping string keys to strings.
+ `paragraphs` is a sequence of paragraph objects.
+ """
+
+ # Key/value pairs for title page
+ if title_page is None:
+ self.title_page = {}
+ else:
+ self.title_page = title_page
+
+ # The paragraphs of the actual script
+ if paragraphs is None:
+ self.paragraphs = []
+ else:
+ self.paragraphs = paragraphs
+
+ def get_rich_attribute(self, name, default=[]):
+ """Get an attribute from the title page parsed into a RichString.
+ Returns a list of RichString objects.
+
+ E.g. `screenplay.get_rich_attribute('Title')`
+
+ """
+ if name in self.title_page:
+ return [parse_emphasis(line) for line in self.title_page[name]]
+ else:
+ return default
+
+ def append(self, paragraph):
+ """Append a paragraph to this screenplay."""
+ self.paragraphs.append(paragraph)
+
+ def __iter__(self):
+ """Get an iterator over the paragraphs of this screenplay."""
+ return iter(self.paragraphs)
+
class Slug(object):
@@ -46,10 +90,11 @@ class Section(object):
class Dialog(object):
- def __init__(self, character, lines):
+ def __init__(self, character, lines=None):
self.character = character
self.blocks = [] # list of tuples of (is_parenthetical, text)
- self._parse(lines)
+ if lines:
+ self._parse(lines)
def _parse(self, lines):
inside_parenthesis = False
@@ -60,6 +105,10 @@ class Dialog(object):
if line.endswith(')'):
inside_parenthesis = False
+ def add_line(self, line):
+ parenthetical = line.startswith('(')
+ self.blocks.append((parenthetical, line))
+
class DualDialog(object):
def __init__(self, left_dialog, right_dialog):
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..c5601f9
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,26 @@
+#!/usr/bin/env python
+
+from distutils.core import setup
+
+setup(
+ name='screenplain',
+ version='0.7.0',
+ description='Convert text file to viewable screenplay.',
+ author='Martin Vilcans',
+ author_email='screenplain@librador.com',
+ url='http://www.screenplain.com/',
+ extras_require={
+ 'PDF': 'reportlab'
+ },
+ packages=[
+ 'screenplain',
+ 'screenplain.export',
+ 'screenplain.parsers',
+ ],
+ package_data={
+ 'screenplain.export': ['default.css']
+ },
+ scripts=[
+ 'bin/screenplain'
+ ]
+)
diff --git a/tests/files/dual-dialogue.fountain.fdx b/tests/files/dual-dialogue.fountain.fdx
new file mode 100644
index 0000000..16d022e
--- /dev/null
+++ b/tests/files/dual-dialogue.fountain.fdx
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<FinalDraft DocumentType="Script" Template="No" Version="1">
+ <Content>
+ <Paragraph>
+ <DualDialogue>
+ <Paragraph Type="Character">
+ <Text>GIRL</Text>
+ </Paragraph>
+ <Paragraph Type="Dialogue">
+ <Text>Hey!</Text>
+ </Paragraph>
+ <Paragraph Type="Character">
+ <Text>GUY</Text>
+ </Paragraph>
+ <Paragraph Type="Dialogue">
+ <Text>Hello!</Text>
+ </Paragraph>
+ </DualDialogue>
+ </Paragraph>
+ </Content>
+</FinalDraft>
diff --git a/tests/files/indentation.fountain b/tests/files/indentation.fountain
new file mode 100644
index 0000000..1640032
--- /dev/null
+++ b/tests/files/indentation.fountain
@@ -0,0 +1,11 @@
+EXT. INDENTATION TEST
+
+ Four spaces
+
+ Three spaces
+
+ Two spaces
+
+ One space
+
+No spaces
diff --git a/tests/files/indentation.fountain.fdx b/tests/files/indentation.fountain.fdx
new file mode 100644
index 0000000..958417f
--- /dev/null
+++ b/tests/files/indentation.fountain.fdx
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<FinalDraft DocumentType="Script" Template="No" Version="1">
+
+ <Content>
+ <Paragraph Type="Scene Heading">
+ <Text>EXT. INDENTATION TEST</Text>
+ </Paragraph>
+ <Paragraph Type="Action">
+ <Text> Four spaces</Text>
+ </Paragraph>
+ <Paragraph Type="Action">
+ <Text> Three spaces</Text>
+ </Paragraph>
+ <Paragraph Type="Action">
+ <Text> Two spaces</Text>
+ </Paragraph>
+ <Paragraph Type="Action">
+ <Text> One space</Text>
+ </Paragraph>
+ <Paragraph Type="Action">
+ <Text>No spaces</Text>
+ </Paragraph>
+ </Content>
+</FinalDraft>
diff --git a/tests/files/indentation.fountain.html b/tests/files/indentation.fountain.html
new file mode 100644
index 0000000..491b515
--- /dev/null
+++ b/tests/files/indentation.fountain.html
@@ -0,0 +1,6 @@
+<h6>EXT. INDENTATION TEST</h6>
+<div class="action"><p>&nbsp;&nbsp;&nbsp; Four spaces</p></div>
+<div class="action"><p>&nbsp;&nbsp; Three spaces</p></div>
+<div class="action"><p>&nbsp; Two spaces</p></div>
+<div class="action"><p>&nbsp;One space</p></div>
+<div class="action"><p>No spaces</p></div>
diff --git a/tests/files/utf-8-bom.fountain b/tests/files/utf-8-bom.fountain
new file mode 100644
index 0000000..ddfe1f9
--- /dev/null
+++ b/tests/files/utf-8-bom.fountain
@@ -0,0 +1,18 @@
+EXT. SOMEWHERE - DAY
+
+GUY and GURL walk down the street.
+
+It's a sunny day.
+Sunnier than normal.
+Too sunny to be funny.
+
+GUY
+So what's up?
+
+GURL
+Nothing much.
+Just thinking.
+And you?
+
+GUY
+Nothing. \ No newline at end of file
diff --git a/tests/files/utf-8-bom.fountain.fdx b/tests/files/utf-8-bom.fountain.fdx
new file mode 100644
index 0000000..9701f3a
--- /dev/null
+++ b/tests/files/utf-8-bom.fountain.fdx
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<FinalDraft DocumentType="Script" Template="No" Version="1">
+
+ <Content>
+ <Paragraph Type="Scene Heading">
+ <Text>EXT. SOMEWHERE - DAY</Text>
+ </Paragraph>
+ <Paragraph Type="Action">
+ <Text>GUY and GURL walk down the street.</Text>
+ </Paragraph>
+ <Paragraph Type="Action">
+ <Text>It's a sunny day.
+</Text>
+ <Text>Sunnier than normal.
+</Text>
+ <Text>Too sunny to be funny.</Text>
+ </Paragraph>
+ <Paragraph Type="Character">
+ <Text>GUY</Text>
+ </Paragraph>
+ <Paragraph Type="Dialogue">
+ <Text>So what's up?</Text>
+ </Paragraph>
+ <Paragraph Type="Character">
+ <Text>GURL</Text>
+ </Paragraph>
+ <Paragraph Type="Dialogue">
+ <Text>Nothing much.</Text>
+ </Paragraph>
+ <Paragraph Type="Dialogue">
+ <Text>Just thinking.</Text>
+ </Paragraph>
+ <Paragraph Type="Dialogue">
+ <Text>And you?</Text>
+ </Paragraph>
+ <Paragraph Type="Character">
+ <Text>GUY</Text>
+ </Paragraph>
+ <Paragraph Type="Dialogue">
+ <Text>Nothing.</Text>
+ </Paragraph>
+ </Content>
+</FinalDraft>
diff --git a/tests/files/utf-8-bom.fountain.html b/tests/files/utf-8-bom.fountain.html
new file mode 100644
index 0000000..70043dd
--- /dev/null
+++ b/tests/files/utf-8-bom.fountain.html
@@ -0,0 +1,6 @@
+<h6>EXT. SOMEWHERE - DAY</h6>
+<div class="action"><p>GUY and GURL walk down the street.</p></div>
+<div class="action"><p>It's a sunny day.<br/>Sunnier than normal.<br/>Too sunny to be funny.</p></div>
+<div class="dialog"><p class="character">GUY</p><p>So what's up?</p></div>
+<div class="dialog"><p class="character">GURL</p><p>Nothing much.</p><p>Just thinking.</p><p>And you?</p></div>
+<div class="dialog"><p class="character">GUY</p><p>Nothing.</p></div>
diff --git a/tests/fountain_test.py b/tests/fountain_test.py
index f100a5c..c31cc66 100644
--- a/tests/fountain_test.py
+++ b/tests/fountain_test.py
@@ -14,25 +14,25 @@ from six import StringIO
def parse(lines):
content = '\n'.join(lines)
- return fountain.parse(StringIO(content))
+ return list(fountain.parse(StringIO(content)))
class SlugTests(TestCase):
def test_slug_with_prefix(self):
- paras = list(parse([
+ paras = parse([
'INT. SOMEWHERE - DAY',
'',
'THIS IS JUST ACTION',
- ]))
+ ])
self.assertEquals([Slug, Action], [type(p) for p in paras])
def test_slug_must_be_single_line(self):
- paras = list(parse([
+ paras = parse([
'INT. SOMEWHERE - DAY',
'ANOTHER LINE',
'',
'Some action',
- ]))
+ ])
self.assertEquals([Dialog, Action], [type(p) for p in paras])
# What looks like a scene headingis parsed as a character name.
# Unexpected perhaps, but that's how I interpreted the spec.
@@ -40,10 +40,10 @@ class SlugTests(TestCase):
self.assertEquals([plain('Some action')], paras[1].lines)
def test_action_is_not_a_slug(self):
- paras = list(parse([
+ paras = parse([
'',
'THIS IS JUST ACTION',
- ]))
+ ])
self.assertEquals([Action], [type(p) for p in paras])
def test_two_lines_creates_no_slug(self):
@@ -148,28 +148,47 @@ class DialogTests(TestCase):
self.assertEquals(Dialog, type(dialog))
self.assertEquals(plain('SOME GUY'), dialog.character)
- # Fountain would not be able to support a character named "23". We
- # might need a syntax to force a character element.
+ def test_alphanumeric_character(self):
+ paras = parse([
+ 'R2D2',
+ 'Bee-bop',
+ ])
+ self.assertEquals([Dialog], [type(p) for p in paras])
+ self.assertEquals(plain('R2D2'), paras[0].character)
+
+ # Spec http://fountain.io/syntax#section-character:
+ # Character names must include at least one alphabetical character.
+ # "R2D2" works, but "23" does not.
def test_nonalpha_character(self):
- paras = list(parse([
+ paras = parse([
'23',
'Hello',
- ]))
+ ])
self.assertEquals([Action], [type(p) for p in paras])
+ # Spec http://fountain.io/syntax#section-character:
+ # You can force a Character element by preceding it with the "at" symbol @.
+ def test_at_sign_forces_dialog(self):
+ paras = parse([
+ '@McCLANE',
+ 'Yippee ki-yay',
+ ])
+ self.assertEquals([Dialog], [type(p) for p in paras])
+ self.assertEquals(plain('McCLANE'), paras[0].character)
+
def test_twospaced_line_is_not_character(self):
- paras = list(parse([
+ paras = parse([
'SCANNING THE AISLES... ',
'Where is that pit boss?',
- ]))
+ ])
self.assertEquals([Action], [type(p) for p in paras])
def test_simple_parenthetical(self):
- paras = list(parse([
+ paras = parse([
'STEEL',
'(starting the engine)',
'So much for retirement!',
- ]))
+ ])
self.assertEquals(1, len(paras))
dialog = paras[0]
self.assertEqual(2, len(dialog.blocks))
@@ -183,12 +202,12 @@ class DialogTests(TestCase):
)
def test_twospace_keeps_dialog_together(self):
- paras = list(parse([
+ paras = parse([
'SOMEONE',
'One',
' ',
'Two',
- ]))
+ ])
self.assertEquals([Dialog], [type(p) for p in paras])
self.assertEquals([
(False, plain('One')),
@@ -197,13 +216,13 @@ class DialogTests(TestCase):
], paras[0].blocks)
def test_dual_dialog(self):
- paras = list(parse([
+ paras = parse([
'BRICK',
'Fuck retirement.',
'',
'STEEL ^',
'Fuck retirement!',
- ]))
+ ])
self.assertEquals([DualDialog], [type(p) for p in paras])
dual = paras[0]
self.assertEquals(plain('BRICK'), dual.left.character)
@@ -218,12 +237,12 @@ class DialogTests(TestCase):
)
def test_dual_dialog_without_previous_dialog_is_ignored(self):
- paras = list(parse([
+ paras = parse([
'Brick strolls down the street.',
'',
'BRICK ^',
'Nice retirement.',
- ]))
+ ])
self.assertEquals([Action, Dialog], [type(p) for p in paras])
dialog = paras[1]
self.assertEqual(plain('BRICK ^'), dialog.character)
@@ -232,13 +251,13 @@ class DialogTests(TestCase):
], dialog.blocks)
def test_leading_and_trailing_spaces_in_dialog(self):
- paras = list(parse([
+ paras = parse([
'JULIET',
'O Romeo, Romeo! wherefore art thou Romeo?',
' Deny thy father and refuse thy name; ',
'Or, if thou wilt not, be but sworn my love,',
" And I'll no longer be a Capulet.",
- ]))
+ ])
self.assertEquals([Dialog], [type(p) for p in paras])
self.assertEquals([
(False, plain(u'O Romeo, Romeo! wherefore art thou Romeo?')),
@@ -251,84 +270,84 @@ class DialogTests(TestCase):
class TransitionTests(TestCase):
def test_standard_transition(self):
- paras = list(parse([
+ paras = parse([
'Jack begins to argue vociferously in Vietnamese (?)',
'',
'CUT TO:',
'',
"EXT. BRICK'S POOL - DAY",
- ]))
+ ])
self.assertEquals([Action, Transition, Slug], [type(p) for p in paras])
def test_transition_must_end_with_to(self):
- paras = list(parse([
+ paras = parse([
'CUT TOO:',
'',
"EXT. BRICK'S POOL - DAY",
- ]))
+ ])
self.assertEquals([Action, Slug], [type(p) for p in paras])
def test_transition_needs_to_be_upper_case(self):
- paras = list(parse([
+ paras = parse([
'Jack begins to argue vociferously in Vietnamese (?)',
'',
'cut to:',
'',
"EXT. BRICK'S POOL - DAY",
- ]))
+ ])
self.assertEquals([Action, Action, Slug], [type(p) for p in paras])
def test_not_a_transition_on_trailing_whitespace(self):
- paras = list(parse([
+ paras = parse([
'Jack begins to argue vociferously in Vietnamese (?)',
'',
'CUT TO: ',
'',
"EXT. BRICK'S POOL - DAY",
- ]))
+ ])
self.assertEquals([Action, Action, Slug], [type(p) for p in paras])
def test_transition_does_not_have_to_be_followed_by_slug(self):
# The "followed by slug" requirement is gone from the Jan 2012 spec
- paras = list(parse([
+ paras = parse([
'Bill lights a cigarette.',
'',
'CUT TO:',
'',
'SOME GUY mowing the lawn.',
- ]))
+ ])
self.assertEquals(
[Action, Transition, Action],
[type(p) for p in paras]
)
def test_greater_than_sign_means_transition(self):
- paras = list(parse([
+ paras = parse([
'Bill blows out the match.',
'',
'> FADE OUT.',
'',
'.DARKNESS',
- ]))
+ ])
self.assertEquals([Action, Transition, Slug], [type(p) for p in paras])
self.assertEquals(plain('FADE OUT.'), paras[1].line)
def test_centered_text_is_not_parsed_as_transition(self):
- paras = list(parse([
+ paras = parse([
'Bill blows out the match.',
'',
'> THE END. <',
'',
'bye!'
- ]))
+ ])
self.assertEquals([Action, Action, Action], [type(p) for p in paras])
def test_transition_at_end(self):
- paras = list(parse([
+ paras = parse([
'They stroll hand in hand down the street.',
'',
'> FADE OUT.',
- ]))
+ ])
self.assertEquals([Action, Transition], [type(p) for p in paras])
self.assertEquals(plain('FADE OUT.'), paras[1].line)
@@ -336,12 +355,12 @@ class TransitionTests(TestCase):
class ActionTests(TestCase):
def test_action_preserves_leading_whitespace(self):
- paras = list(parse([
+ paras = parse([
'hello',
'',
' two spaces',
' three spaces ',
- ]))
+ ])
self.assertEquals([Action, Action], [type(p) for p in paras])
self.assertEquals(
[
@@ -351,7 +370,7 @@ class ActionTests(TestCase):
)
def test_single_centered_line(self):
- paras = list(parse(['> center me! <']))
+ paras = parse(['> center me! <'])
self.assertEquals([Action], [type(p) for p in paras])
self.assertTrue(paras[0].centered)
@@ -361,7 +380,7 @@ class ActionTests(TestCase):
' > second! <',
'> third!< ',
]
- paras = list(parse(lines))
+ paras = parse(lines)
self.assertEquals([Action], [type(p) for p in paras])
self.assertTrue(paras[0].centered)
self.assertEquals([
@@ -371,11 +390,11 @@ class ActionTests(TestCase):
], paras[0].lines)
def test_upper_case_centered_not_parsed_as_dialog(self):
- paras = list(parse([
+ paras = parse([
'> FIRST! <',
' > SECOND! <',
'> THIRD! <',
- ]))
+ ])
self.assertEquals([Action], [type(p) for p in paras])
self.assertTrue(paras[0].centered)
@@ -385,7 +404,7 @@ class ActionTests(TestCase):
'> second! <',
'third!',
]
- paras = list(parse(lines))
+ paras = parse(lines)
self.assertEquals([Action], [type(p) for p in paras])
self.assertFalse(paras[0].centered)
self.assertEquals([plain(line) for line in lines], paras[0].lines)
@@ -393,12 +412,12 @@ class ActionTests(TestCase):
class SynopsisTests(TestCase):
def test_synopsis_after_slug_adds_synopsis_to_scene(self):
- paras = list(parse([
+ paras = parse([
"EXT. BRICK'S PATIO - DAY",
'',
"= Set up Brick & Steel's new life."
'',
- ]))
+ ])
self.assertEquals([Slug], [type(p) for p in paras])
self.assertEquals(
"Set up Brick & Steel's new life.",
@@ -406,11 +425,11 @@ class SynopsisTests(TestCase):
)
def test_synopsis_in_section(self):
- paras = list(parse([
+ paras = parse([
'# section one',
'',
'= In which we get to know our characters'
- ]))
+ ])
self.assertEquals([Section], [type(p) for p in paras])
self.assertEquals(
'In which we get to know our characters',
@@ -418,11 +437,11 @@ class SynopsisTests(TestCase):
)
def test_synopsis_syntax_parsed_as_literal(self):
- paras = list(parse([
+ paras = parse([
'Some action',
'',
'= A line that just happens to look like a synopsis'
- ]))
+ ])
self.assertEquals([Action, Action], [type(p) for p in paras])
self.assertEquals(
[plain('= A line that just happens to look like a synopsis')],
@@ -485,9 +504,9 @@ class TitlePageTests(TestCase):
class PageBreakTests(TestCase):
def test_page_break_is_parsed(self):
- paras = list(parse([
+ paras = parse([
'====',
'',
'So here we go'
- ]))
+ ])
self.assertEquals([PageBreak, Action], [type(p) for p in paras])