diff options
author | John Peter Yamauchi <johnpeteryams@gmail.com> | 2019-02-19 22:03:50 -0600 |
---|---|---|
committer | John Peter Yamauchi <johnpeteryams@gmail.com> | 2019-02-19 22:03:50 -0600 |
commit | 16af7101e490ce86aef57f0ed0eb475741fa06a4 (patch) | |
tree | 07ab703d2e8cfcd47b055c46bf53a411fe01c526 | |
parent | 9c2e3683e207e81cf88350fde2dfadcecd5cc914 (diff) | |
parent | d9eb1a980798ff54ac9cd81ff1821f78aa57156b (diff) | |
download | screenplain-16af7101e490ce86aef57f0ed0eb475741fa06a4.tar.gz |
Merge branch 'master' into python3
-rw-r--r-- | .gitignore | 7 | ||||
-rw-r--r-- | .travis.yml | 3 | ||||
-rw-r--r-- | README.markdown | 19 | ||||
-rwxr-xr-x | bin/screenplain | 3 | ||||
-rwxr-xr-x | bin/test | 3 | ||||
-rw-r--r-- | requirements.txt | 2 | ||||
-rw-r--r-- | screenplain/export/html.py | 19 | ||||
-rw-r--r-- | screenplain/export/pdf.py | 277 | ||||
-rw-r--r-- | screenplain/main.py | 77 | ||||
-rw-r--r-- | screenplain/parsers/fountain.py | 32 | ||||
-rw-r--r-- | screenplain/richstring.py | 20 | ||||
-rw-r--r-- | screenplain/types.py | 53 | ||||
-rwxr-xr-x | setup.py | 26 | ||||
-rw-r--r-- | tests/files/dual-dialogue.fountain.fdx | 21 | ||||
-rw-r--r-- | tests/files/indentation.fountain | 11 | ||||
-rw-r--r-- | tests/files/indentation.fountain.fdx | 24 | ||||
-rw-r--r-- | tests/files/indentation.fountain.html | 6 | ||||
-rw-r--r-- | tests/files/utf-8-bom.fountain | 18 | ||||
-rw-r--r-- | tests/files/utf-8-bom.fountain.fdx | 43 | ||||
-rw-r--r-- | tests/files/utf-8-bom.fountain.html | 6 | ||||
-rw-r--r-- | tests/fountain_test.py | 127 |
21 files changed, 690 insertions, 107 deletions
@@ -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:]) @@ -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 ' ' - return re.sub(' ', ' ', 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 ' ' + 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: ' ' * (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> Four spaces</p></div> +<div class="action"><p> Three spaces</p></div> +<div class="action"><p> Two spaces</p></div> +<div class="action"><p> 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]) |