changeset 13:ee741bbe96a8

Rename to 'Jouvence'.
author Ludovic Chabant <ludovic@chabant.com>
date Wed, 04 Jan 2017 09:02:29 -0800
parents eea60b93da2c
children 1eb3c8ac8940
files README.rst fontaine/__init__.py fontaine/cli.py fontaine/console.py fontaine/document.py fontaine/html.py fontaine/parser.py fontaine/renderer.py fontaine/resources/html_footer.html fontaine/resources/html_header.html fontaine/resources/html_styles.css fontaine/resources/html_styles.scss jouvence/__init__.py jouvence/cli.py jouvence/console.py jouvence/document.py jouvence/html.py jouvence/parser.py jouvence/renderer.py jouvence/resources/html_footer.html jouvence/resources/html_header.html jouvence/resources/html_styles.css jouvence/resources/html_styles.scss jouvence/version.py scripts/fontaine scripts/jouvence setup.py tests/conftest.py tests/test_renderer.py
diffstat 29 files changed, 1227 insertions(+), 1223 deletions(-) [+]
line wrap: on
line diff
--- a/README.rst	Wed Jan 04 08:51:32 2017 -0800
+++ b/README.rst	Wed Jan 04 09:02:29 2017 -0800
@@ -1,13 +1,13 @@
 
 ########
-FONTAINE
+JOUVENCE
 ########
 
 
-`Fountain`_ is a plain text markup language for screenwriting. Fontaine
+`Fountain`_ is a plain text markup language for screenwriting. Jouvence
 is a Python package for parsing and rendering Fountain documents.
 
-Fontaine supports:
+Jouvence supports:
 
 * Most of the Fountain specification (see limitations below).
 * Rendering to HTML and terminals.
@@ -19,16 +19,16 @@
 ============
 
 As with many Python packages, it's recommended that you use `virtualenv`_,
-but since Fontaine doesn't have many dependencies, you should be fine.
+but since Jouvence doesn't have many dependencies, you should be fine.
 
-You can install Fontaine the usual way::
+You can install Jouvence the usual way::
 
-  pip install fontaine
+  pip install jouvence
 
 If you want to test that it works, you can feed it a Fountain screenplay and
 see if it prints it nicely in your terminal::
 
-  fontaine <path-to-fountain-file>
+  jouvence <path-to-fountain-file>
 
 You should then see the Fountain file rendered with colored and indented
 styles.
@@ -39,12 +39,12 @@
 Usage
 =====
 
-The Fontaine API goes pretty much like this::
+The Jouvence API goes pretty much like this::
 
-  from fontaine.parser import FontaineParser
-  from fontaine.html import HtmlDocumentRenderer
+  from jouvence.parser import JouvenceParser
+  from jouvence.html import HtmlDocumentRenderer
 
-  parser = FontaineParser()
+  parser = JouvenceParser()
   document = parser.parse(path_to_file)
   renderer = HtmlDocumentRenderer()
   markup = renderer.render_doc(document)
@@ -54,7 +54,7 @@
 Limitations
 ===========
 
-Fontaine doesn't support the complete Fountain syntax yet. The following things
+Jouvence doesn't support the complete Fountain syntax yet. The following things
 are not implemented yet:
 
 * Dual dialogue
--- a/fontaine/__init__.py	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-try:
-    from .version import version
-except ImportError:
-    version = '<unknown>'
--- a/fontaine/cli.py	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
-import sys
-import argparse
-
-
-def main():
-    parser = argparse.ArgumentParser(
-        description='Fontaine command line utility')
-    parser.add_argument('script')
-    parser.add_argument('out_file', nargs='?')
-    args = parser.parse_args()
-
-    from fontaine.parser import FontaineParser
-    p = FontaineParser()
-    doc = p.parse(args.script)
-
-    if not args.out_file:
-        from fontaine.console import ConsoleDocumentRenderer
-        rdr = ConsoleDocumentRenderer()
-        rdr.render_doc(doc, sys.stdout)
-    else:
-        from fontaine.html import HtmlDocumentRenderer
-        rdr = HtmlDocumentRenderer()
-        with open(args.out_file, 'w') as fp:
-            rdr.render_doc(doc, fp)
--- a/fontaine/console.py	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,93 +0,0 @@
-import os
-import colorama
-from .renderer import BaseDocumentRenderer, BaseTextRenderer
-
-
-def _w(out, style, text, reset_all=False):
-    f = out.write
-    f(style)
-    f(text)
-    if not reset_all:
-        f(colorama.Style.NORMAL)
-    else:
-        f(colorama.Style.RESET_ALL)
-    f(os.linesep)
-
-
-class ConsoleDocumentRenderer(BaseDocumentRenderer):
-    def __init__(self, width=80):
-        super().__init__(ConsoleTextRenderer())
-        self.width = width
-        colorama.init()
-
-    def write_title_page(self, values, out):
-        known = ['title', 'credit', 'author', 'source']
-        center_values = [values.get(i) for i in known
-                         if i is not None]
-
-        print("", file=out)
-        for val in center_values:
-            for l in val.split('\n'):
-                print(l.center(self.width), file=out)
-            print("", file=out)
-        print("", file=out)
-        print("", file=out)
-
-        ddate = values.get('date') or values.get('draft date')
-        contact = values.get('contact')
-        bottom_lines = [i for i in [ddate, contact]
-                        if i is not None]
-
-        _w(out, colorama.Style.DIM, '\n\n'.join(bottom_lines))
-        print("", file=out)
-        _w(out, colorama.Style.DIM, 80 * '=')
-
-    def write_scene_heading(self, text, out):
-        print("", file=out)
-        _w(out, colorama.Fore.WHITE + colorama.Style.BRIGHT, text, True)
-
-    def write_action(self, text, out):
-        print(text, file=out)
-
-    def write_centeredaction(self, text, out):
-        print("", file=out)
-        for line in text.split('\n'):
-            print(line.center(self.width), file=out)
-
-    def write_character(self, text, out):
-        print("", file=out)
-        _w(out, colorama.Fore.WHITE, "\t\t\t" + text, True)
-
-    def write_dialog(self, text, out):
-        for line in text.split('\n'):
-            print("\t" + line, file=out)
-
-    def write_parenthetical(self, text, out):
-        for line in text.split('\n'):
-            print("\t\t" + line, file=out)
-
-    def write_transition(self, text, out):
-        print("", file=out)
-        print("\t\t\t\t" + text, file=out)
-
-    def write_lyrics(self, text, out):
-        print("", file=out)
-        _w(out, colorama.Fore.MAGENTA, text, True)
-
-    def write_pagebreak(self, out):
-        print("", file=out)
-        _w(out, colorama.Style.DIM, 80 * '=')
-
-
-class ConsoleTextRenderer(BaseTextRenderer):
-    def _writeStyled(self, style, text):
-        return style + text + colorama.Style.NORMAL
-
-    def make_italics(self, text):
-        return self._writeStyled(colorama.Style.BRIGHT, text)
-
-    def make_bold(self, text):
-        return self._writeStyled(colorama.Style.BRIGHT, text)
-
-    def make_underline(self, text):
-        return self._writeStyled(colorama.Style.BRIGHT, text)
--- a/fontaine/document.py	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,116 +0,0 @@
-import sys
-
-
-class FontaineDocument:
-    def __init__(self):
-        self.title_values = {}
-        self.scenes = []
-
-    def addScene(self, header=None):
-        s = FontaineScene()
-        if header:
-            s.header = header
-        self.scenes.append(s)
-        return s
-
-    def lastScene(self, auto_create=True):
-        try:
-            return self.scenes[-1]
-        except IndexError:
-            if auto_create:
-                s = self.addScene()
-                return s
-            return None
-
-    def lastParagraph(self):
-        s = self.lastScene(False)
-        if s:
-            return s.lastParagraph()
-        return None
-
-
-class FontaineScene:
-    def __init__(self):
-        self.header = None
-        self.paragraphs = []
-        self._adders = {}
-
-    def __getattr__(self, name):
-        if name.startswith('add'):
-            add_type_name = name[3:]
-            try:
-                adder = self._adders[add_type_name]
-            except KeyError:
-                module = sys.modules[__name__]
-                add_type = getattr(module,
-                                   'TYPE_%s' % add_type_name.upper())
-
-                def _type_adder(_text):
-                    new_p = FontaineSceneElement(add_type, _text)
-                    self.paragraphs.append(new_p)
-                    return new_p
-
-                adder = _type_adder
-                self._adders[add_type_name] = adder
-            return adder
-        else:
-            raise AttributeError
-
-    def addPageBreak(self):
-        self.paragraphs.append(FontaineSceneElement(TYPE_PAGEBREAK, None))
-
-    def lastParagraph(self):
-        try:
-            return self.paragraphs[-1]
-        except IndexError:
-            return None
-
-
-class FontaineSceneElement:
-    def __init__(self, el_type, text):
-        self.type = el_type
-        self.text = text
-
-    def __str__(self):
-        return '%s: %s' % (
-            _scene_element_type_str(self.type),
-            _ellipsis(self.text, 15))
-
-
-TYPE_ACTION = 0
-TYPE_CENTEREDACTION = 1
-TYPE_CHARACTER = 2
-TYPE_DIALOG = 3
-TYPE_PARENTHETICAL = 4
-TYPE_TRANSITION = 5
-TYPE_LYRICS = 6
-TYPE_PAGEBREAK = 7
-TYPE_EMPTYLINES = 8
-
-
-def _scene_element_type_str(t):
-    if t == TYPE_ACTION:
-        return 'ACTION'
-    if t == TYPE_CENTEREDACTION:
-        return 'CENTEREDACTION'
-    if t == TYPE_CHARACTER:
-        return 'CHARACTER'
-    if t == TYPE_DIALOG:
-        return 'DIALOG'
-    if t == TYPE_PARENTHETICAL:
-        return 'PARENTHETICAL'
-    if t == TYPE_TRANSITION:
-        return 'TRANSITION'
-    if t == TYPE_LYRICS:
-        return 'LYRICS'
-    if t == TYPE_PAGEBREAK:
-        return 'PAGEBREAK'
-    if t == TYPE_EMPTYLINES:
-        return 'EMPTYLINES'
-    raise NotImplementedError()
-
-
-def _ellipsis(text, length):
-    if len(text) > length:
-        return text[:length - 3] + '...'
-    return text
--- a/fontaine/html.py	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,110 +0,0 @@
-import os.path
-from markupsafe import escape
-from .renderer import BaseDocumentRenderer, BaseTextRenderer
-
-
-def _elem(out, elem_name, class_name, contents):
-    f = out.write
-    f('<%s' % elem_name)
-    if class_name:
-        f(' class="fontaine-%s"' % class_name)
-    f('>')
-    f(contents)
-    f('</%s>\n' % elem_name)
-
-
-def _br(text, strip_first=False):
-    lines = text.split('\n')
-    if strip_first and lines[0].strip() == '':
-        lines = lines[1:]
-    return '<br/>\n'.join(lines)
-
-
-def _res(filename):
-    path = os.path.join(os.path.dirname(__file__), 'resources', filename)
-    with open(path, 'r') as fp:
-        return fp.read()
-
-
-class HtmlDocumentRenderer(BaseDocumentRenderer):
-    def __init__(self, standalone=True):
-        super().__init__(HtmlTextRenderer())
-        self.standalone = standalone
-
-    def get_css(self):
-        return _res('html_styles.css')
-
-    def write_header(self, doc, out):
-        if self.standalone:
-            meta = doc.title_values.get
-            data = {
-                # TODO: need a "strip formatting" to have a clean title.
-                'title': meta('title', "Fountain Screenplay"),
-                'description': meta('description', ''),
-                'css': self.get_css()
-            }
-            out.write(_res('html_header.html') % data)
-        out.write('<div class="fontaine-doc">\n')
-
-    def write_footer(self, doc, out):
-        out.write('</div>\n')
-        if self.standalone:
-            out.write(_res('html_footer.html'))
-
-    def write_title_page(self, values, out):
-        out.write('<div class="fontaine-title-page">\n')
-
-        _elem(out, 'h1', None, _br(values['title']))
-        _elem(out, 'p', 'title-page-heading', _br(values['credit']))
-        _elem(out, 'p', 'title-page-heading', _br(values['author']))
-
-        ddate = values.get('date') or values.get('draft date')
-        if ddate:
-            _elem(out, 'p', 'title-page-footer', _br(ddate))
-        contact = values.get('contact')
-        if contact:
-            _elem(out, 'p', 'title-page-footer', _br(contact))
-
-        out.write('</div>\n')
-        self.write_pagebreak(out)
-
-    def write_scene_heading(self, text, out):
-        _elem(out, 'p', 'scene-heading', text)
-
-    def write_action(self, text, out):
-        _elem(out, 'p', 'action', _br(text, True))
-
-    def write_centeredaction(self, text, out):
-        _elem(out, 'p', 'action-centered', _br(text, True))
-
-    def write_character(self, text, out):
-        _elem(out, 'p', 'character', text)
-
-    def write_dialog(self, text, out):
-        _elem(out, 'p', 'dialog', _br(text))
-
-    def write_parenthetical(self, text, out):
-        _elem(out, 'p', 'parenthetical', text)
-
-    def write_transition(self, text, out):
-        _elem(out, 'p', 'transition', text)
-
-    def write_lyrics(self, text, out):
-        _elem(out, 'p', 'lyrics', _br(text, True))
-
-    def write_pagebreak(self, out):
-        out.write('<hr/>\n')
-
-
-class HtmlTextRenderer(BaseTextRenderer):
-    def render_text(self, text):
-        return super().render_text(escape(text))
-
-    def make_italics(self, text):
-        return '<em>%s</em>' % text
-
-    def make_bold(self, text):
-        return '<strong>%s</strong>' % text
-
-    def make_underline(self, text):
-        return '<u>%s</u>' % text
--- a/fontaine/parser.py	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,589 +0,0 @@
-import re
-import logging
-from .document import TYPE_ACTION
-
-
-logger = logging.getLogger(__name__)
-
-
-class FontaineState:
-    def __init__(self):
-        pass
-
-    def match(self, fp, ctx):
-        return False
-
-    def consume(self, fp, ctx):
-        raise NotImplementedError()
-
-    def exit(self, ctx, next_state):
-        pass
-
-
-class _PassThroughState(FontaineState):
-    def consume(self, fp, ctx):
-        return ANY_STATE
-
-
-class FontaineParserError(Exception):
-    def __init__(self, line_no, message):
-        super().__init__("Error line %d: %s" % (line_no, message))
-
-
-ANY_STATE = object()
-EOF_STATE = object()
-
-
-RE_EMPTY_LINE = re.compile(r"^$", re.M)
-RE_BLANK_LINE = re.compile(r"^\s*$", re.M)
-
-RE_TITLE_KEY_VALUE = re.compile(r"^(?P<key>[\w\s\-]+)\s*:\s*")
-
-
-class _TitlePageState(FontaineState):
-    def __init__(self):
-        super().__init__()
-        self._cur_key = None
-        self._cur_val = None
-
-    def match(self, fp, ctx):
-        line = fp.peekline()
-        return RE_TITLE_KEY_VALUE.match(line)
-
-    def consume(self, fp, ctx):
-        while True:
-            line = fp.readline()
-            if not line:
-                return EOF_STATE
-
-            m = RE_TITLE_KEY_VALUE.match(line)
-            if m:
-                # Commit current value, start new one.
-                self._commit(ctx)
-                self._cur_key = m.group('key').lower()
-                self._cur_val = line[m.end():]
-            else:
-                # Keep accumulating the value of one of the title page's
-                # values.
-                self._cur_val += line.lstrip()
-
-            if RE_EMPTY_LINE.match(fp.peekline()):
-                self._commit(ctx)
-                # Finished with the page title, now move on to the first scene.
-                break
-
-        return ANY_STATE
-
-    def exit(self, ctx, next_state):
-        self._commit(ctx)
-
-    def _commit(self, ctx):
-        if self._cur_key is not None:
-            val = self._cur_val.rstrip('\r\n')
-            ctx.document.title_values[self._cur_key] = val
-            self._cur_key = None
-            self._cur_val = None
-
-
-RE_SCENE_HEADER_PATTERN = re.compile(
-    r"^(int|ext|est|int/ext|int./ext|i/e)[\s\.]", re.I)
-
-
-class _SceneHeaderState(FontaineState):
-    def match(self, fp, ctx):
-        lines = fp.peeklines(3)
-        return (
-            RE_EMPTY_LINE.match(lines[0]) and
-            RE_SCENE_HEADER_PATTERN.match(lines[1]) and
-            RE_EMPTY_LINE.match(lines[2]))
-
-    def consume(self, fp, ctx):
-        fp.readline()  # Get past the blank line.
-        line = fp.readline().rstrip('\r\n')
-        line = line.lstrip('.')  # In case it was forced.
-        ctx.document.addScene(line)
-        return ANY_STATE
-
-
-class _ActionState(FontaineState):
-    def __init__(self):
-        super().__init__()
-        self.text = ''
-
-    def match(self, fp, ctx):
-        return True
-
-    def consume(self, fp, ctx):
-        is_first_line = True
-        while True:
-            line = fp.readline()
-            if not line:
-                return EOF_STATE
-
-            if is_first_line:
-                # Ignore the fake blank line at 0 if it's threre.
-                if fp.line_no == 0:
-                    continue
-
-                line = line.lstrip('!')  # In case it was forced.
-                is_first_line = False
-
-            # If the next line is empty, strip the carriage return from
-            # the line we just got because it's probably gonna be the
-            # last one.
-            if RE_EMPTY_LINE.match(fp.peekline()):
-                self.text += line.rstrip("\r\n")
-                break
-            # ...otherwise, add the line with in full.
-            self.text += line
-
-        return ANY_STATE
-
-    def exit(self, ctx, next_state):
-        last_para = ctx.document.lastParagraph()
-        if last_para and last_para.type == TYPE_ACTION:
-            last_para.text += '\n' + self.text
-        else:
-            ctx.document.lastScene().addAction(self.text)
-
-
-RE_CENTERED_LINE = re.compile(r"^\s*>\s*.*\s*<\s*$", re.M)
-
-
-class _CenteredActionState(FontaineState):
-    def __init__(self):
-        super().__init__()
-        self.text = ''
-        self._aborted = False
-
-    def match(self, fp, ctx):
-        lines = fp.peeklines(2)
-        return (
-            RE_EMPTY_LINE.match(lines[0]) and
-            RE_CENTERED_LINE.match(lines[1]))
-
-    def consume(self, fp, ctx):
-        snapshot = fp.snapshot()
-        fp.readline()  # Get past the empty line.
-        while True:
-            line = fp.readline()
-            if not line:
-                return EOF_STATE
-
-            clean_line = line.rstrip('\r\n')
-            eol = line[len(clean_line):]
-
-            clean_line = clean_line.strip()
-            if clean_line[0] != '>' or clean_line[-1] != '<':
-                # The whole paragraph must have `>` and `<` wrappers, so
-                # if we detect a line that doesn't have them, we make this
-                # paragraph be a normal action instead.
-                fp.restore(snapshot)
-                self._aborted = True
-                return _ActionState()
-            else:
-                # Remove wrapping `>`/`<`, and spaces.
-                clean_line = clean_line[1:-1].strip()
-
-            if RE_EMPTY_LINE.match(fp.peekline()):
-                self.text += clean_line
-                break
-            self.text += clean_line + eol
-
-        return ANY_STATE
-
-    def exit(self, ctx, next_state):
-        if not self._aborted:
-            ctx.document.lastScene().addCenteredAction(self.text)
-
-
-RE_CHARACTER_LINE = re.compile(r"^\s*[A-Z][A-Z\-\._\s]+\s*(\(.*\))?$", re.M)
-
-
-class _CharacterState(FontaineState):
-    def match(self, fp, ctx):
-        lines = fp.peeklines(3)
-        return (RE_EMPTY_LINE.match(lines[0]) and
-                RE_CHARACTER_LINE.match(lines[1]) and
-                not RE_EMPTY_LINE.match(lines[2]))
-
-    def consume(self, fp, ctx):
-        fp.readline()  # Get past the empty line.
-        line = fp.readline().rstrip('\r\n')
-        line = line.lstrip()  # Remove indenting.
-        line = line.lstrip('@')  # In case it was forced.
-        ctx.document.lastScene().addCharacter(line)
-        return [_ParentheticalState, _DialogState]
-
-
-RE_PARENTHETICAL_LINE = re.compile(r"^\s*\(.*\)\s*$", re.M)
-
-
-class _ParentheticalState(FontaineState):
-    def match(self, fp, ctx):
-        # We only get here from a `_CharacterState` so we know the previous
-        # one is already that.
-        line = fp.peekline()
-        return RE_PARENTHETICAL_LINE.match(line)
-
-    def consume(self, fp, ctx):
-        line = fp.readline().lstrip().rstrip('\r\n')
-        ctx.document.lastScene().addParenthetical(line)
-
-        next_line = fp.peekline()
-        if not RE_EMPTY_LINE.match(next_line):
-            return _DialogState()
-
-        return ANY_STATE
-
-
-class _DialogState(FontaineState):
-    def __init__(self):
-        super().__init__()
-        self.text = ''
-
-    def match(self, fp, ctx):
-        # We only get here from a `_CharacterState` or `_ParentheticalState`
-        # so we just need to check there's some text.
-        line = fp.peekline()
-        return not RE_EMPTY_LINE.match(line)
-
-    def consume(self, fp, ctx):
-        while True:
-            line = fp.readline()
-            if not line:
-                return EOF_STATE
-
-            line = line.lstrip()  # Remove indenting.
-
-            # Next we could be either continuing the dialog line, going to
-            # a parenthetical, or exiting dialog altogether.
-            next_line = fp.peekline()
-
-            if RE_PARENTHETICAL_LINE.match(next_line):
-                self.text += line.rstrip('\r\n')
-                return _ParentheticalState()
-
-            if RE_EMPTY_LINE.match(next_line):
-                self.text += line.rstrip('\r\n')
-                break
-            self.text += line
-
-        return ANY_STATE
-
-    def exit(self, ctx, next_state):
-        ctx.document.lastScene().addDialog(self.text.rstrip('\r\n'))
-
-
-class _LyricsState(FontaineState):
-    def __init__(self):
-        super().__init__()
-        self.text = ''
-        self._aborted = False
-
-    # No `match` method, this can only be forced.
-    # (see `_ForcedParagraphStates`)
-
-    def consume(self, fp, ctx):
-        snapshot = fp.snapshot()
-        fp.readline()  # Get past the empty line.
-        while True:
-            line = fp.readline()
-            if not line:
-                return EOF_STATE
-
-            if line.startswith('~'):
-                line = line.lstrip('~')
-            else:
-                logger.debug("Rolling back lyrics into action paragraph.")
-                fp.restore(snapshot)
-                self._aborted = True
-                return _ActionState()
-
-            if RE_EMPTY_LINE.match(fp.peekline()):
-                self.text += line.rstrip('\r\n')
-                break
-            self.text += line
-
-        return ANY_STATE
-
-    def exit(self, ctx, next_state):
-        if not self._aborted:
-            ctx.document.lastScene().addLyrics(self.text)
-
-
-RE_TRANSITION_LINE = re.compile(r"^\s*[^a-z]+TO\:$", re.M)
-
-
-class _TransitionState(FontaineState):
-    def match(self, fp, ctx):
-        lines = fp.peeklines(3)
-        return (
-            RE_EMPTY_LINE.match(lines[0]) and
-            RE_TRANSITION_LINE.match(lines[1]) and
-            RE_EMPTY_LINE.match(lines[2]))
-
-    def consume(self, fp, ctx):
-        fp.readline()  # Get past the empty line.
-        line = fp.readline().lstrip().rstrip('\r\n')
-        line = line.lstrip('>')  # In case it was forced.
-        ctx.document.lastScene().addTransition(line)
-        return ANY_STATE
-
-
-RE_PAGE_BREAK_LINE = re.compile(r"^\=\=\=+$", re.M)
-
-
-class _PageBreakState(FontaineState):
-    def match(self, fp, ctx):
-        lines = fp.peeklines(3)
-        return (
-            RE_EMPTY_LINE.match(lines[0]) and
-            RE_PAGE_BREAK_LINE.match(lines[1]) and
-            RE_EMPTY_LINE.match(lines[2]))
-
-    def consume(self, fp, ctx):
-        fp.readline()
-        fp.readline()
-        ctx.document.lastScene().addPageBreak()
-        return ANY_STATE
-
-
-class _ForcedParagraphStates(FontaineState):
-    STATE_SYMBOLS = {
-        '.': _SceneHeaderState,
-        '!': _ActionState,
-        '@': _CharacterState,
-        '~': _LyricsState,
-        '>': _TransitionState
-    }
-
-    def __init__(self):
-        super().__init__()
-        self._state_cls = None
-        self._consume_empty_line = False
-
-    def match(self, fp, ctx):
-        lines = fp.peeklines(2)
-        symbol = lines[1][:1]
-        if (RE_EMPTY_LINE.match(lines[0]) and
-                symbol in self.STATE_SYMBOLS):
-            # Special case: don't force a transition state if it's
-            # really some centered text.
-            if symbol == '>' and RE_CENTERED_LINE.match(lines[1]):
-                return False
-
-            self._state_cls = self.STATE_SYMBOLS[symbol]
-
-            # Special case: for forced action paragraphs, don't leave
-            # the blank line there.
-            if symbol == '!':
-                self._consume_empty_line = True
-
-            return True
-        return False
-
-    def consume(self, fp, ctx):
-        if self._consume_empty_line:
-            fp.readline()
-        return self._state_cls()
-
-
-class _EmptyLineState(FontaineState):
-    def __init__(self):
-        super().__init__()
-        self.line_count = 0
-
-    def match(self, fp, ctx):
-        return RE_EMPTY_LINE.match(fp.peekline())
-
-    def consume(self, fp, ctx):
-        fp.readline()
-        if fp.line_no > 1:  # Don't take into account the fake blank at 0
-            self.line_count += 1
-        return ANY_STATE
-
-    def exit(self, ctx, next_state):
-        if self.line_count > 0:
-            text = self.line_count * '\n'
-            last_para = ctx.document.lastParagraph()
-            if last_para and last_para.type == TYPE_ACTION:
-                last_para.text += text
-            else:
-                ctx.document.lastScene().addAction(text[1:])
-
-
-ROOT_STATES = [
-    _ForcedParagraphStates,  # Must be first.
-    _SceneHeaderState,
-    _CharacterState,
-    _TransitionState,
-    _PageBreakState,
-    _CenteredActionState,
-    _EmptyLineState,   # Must be second to last.
-    _ActionState,  # Must be last.
-]
-
-
-class _PeekableFile:
-    def __init__(self, fp):
-        self.line_no = 1
-        self._fp = fp
-        self._blankAt0 = False
-
-    def readline(self):
-        if self._blankAt0:
-            self._blankAt0 = False
-            self.line_no = 0
-            return '\n'
-
-        data = self._fp.readline()
-        self.line_no += 1
-        return data
-
-    def peekline(self):
-        if self._blankAt0:
-            return '\n'
-
-        pos = self._fp.tell()
-        line = self._fp.readline()
-        self._fp.seek(pos)
-        return line
-
-    def peeklines(self, count):
-        pos = self._fp.tell()
-        lines = []
-        if self._blankAt0:
-            lines.append('\n')
-            count -= 1
-        for i in range(count):
-            lines.append(self._fp.readline())
-        self._fp.seek(pos)
-        return lines
-
-    def snapshot(self):
-        return (self._fp.tell(), self._blankAt0, self.line_no)
-
-    def restore(self, snapshot):
-        self._fp.seek(snapshot[0])
-        self._blankAt0 = snapshot[1]
-        self.line_no = snapshot[2]
-
-    def _addBlankAt0(self):
-        if self._fp.tell() != 0:
-            raise Exception(
-                "Can't add blank line at 0 if reading has started.")
-        self._blankAt0 = True
-        self.line_no = 0
-
-
-class _FontaineStateMachine:
-    def __init__(self, fp, doc):
-        self.fp = _PeekableFile(fp)
-        self.state = None
-        self.document = doc
-
-    @property
-    def line_no(self):
-        return self.fp.line_no
-
-    def run(self):
-        # Start with the page title... unless it doesn't match, in which
-        # case we start with a "pass through" state that will just return
-        # `ANY_STATE` so we can start matching stuff.
-        self.state = _TitlePageState()
-        if not self.state.match(self.fp, self):
-            logger.debug("No title page value found on line 1, "
-                         "using pass-through state with added blank line.")
-            self.state = _PassThroughState()
-            if not RE_EMPTY_LINE.match(self.fp.peekline()):
-                # Add a fake empty line at the beginning of the text if
-                # there's not one already. This makes state matching easier.
-                self.fp._addBlankAt0()
-
-        # Start parsing! Here we try to do a mostly-forward-only parser with
-        # non overlapping regexes to make it decently fast.
-        while True:
-            logger.debug("State '%s' consuming from '%s'..." %
-                         (self.state.__class__.__name__, self.fp.peekline()))
-            res = self.state.consume(self.fp, self)
-
-            # See if we reached the end of the file.
-            if not self.fp.peekline():
-                logger.debug("Reached end of line... ending parsing.")
-                res = EOF_STATE
-
-            # Figure out what to do next...
-
-            if res is None:
-                raise FontaineParserError(
-                    self.line_no,
-                    "State '%s' returned a `None` result. "
-                    "States need to return `ANY_STATE`, one or more specific "
-                    "states, or `EOF_STATE` if they reached the end of the "
-                    "file." % self.state.__class__.__name__)
-
-            elif res is ANY_STATE or isinstance(res, list):
-                # State wants to exit, we need to figure out what is the
-                # next state.
-                pos = self.fp._fp.tell()
-                next_states = res
-                if next_states is ANY_STATE:
-                    next_states = ROOT_STATES
-                logger.debug("Trying to match next state from: %s" %
-                             [t.__name__ for t in next_states])
-                for sc in next_states:
-                    s = sc()
-                    if s.match(self.fp, self):
-                        logger.debug("Matched state %s" %
-                                     s.__class__.__name__)
-                        self.fp._fp.seek(pos)
-                        res = s
-                        break
-                else:
-                    raise Exception("Can't match following state after: %s" %
-                                    self.state)
-
-                # Handle the current state before we move on to the new one.
-                if self.state:
-                    self.state.exit(self, res)
-                self.state = res
-
-            elif isinstance(res, FontaineState):
-                # State wants to exit, wants a specific state to be next.
-                if self.state:
-                    self.state.exit(self, res)
-                self.state = res
-
-            elif res is EOF_STATE:
-                # Reached end of file.
-                if self.state:
-                    self.state.exit(self, res)
-                break
-
-            else:
-                raise Exception("Unsupported state result: %s" % res)
-
-
-class FontaineParser:
-    def __init__(self):
-        pass
-
-    def parse(self, filein):
-        if isinstance(filein, str):
-            with open(filein, 'r') as fp:
-                return self._doParse(fp)
-        else:
-            return self._doParse(fp)
-
-    def parseString(self, text):
-        import io
-        with io.StringIO(text) as fp:
-            return self._doParse(fp)
-
-    def _doParse(self, fp):
-        from .document import FontaineDocument
-        doc = FontaineDocument()
-        machine = _FontaineStateMachine(fp, doc)
-        machine.run()
-        return doc
--- a/fontaine/renderer.py	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,146 +0,0 @@
-import re
-from fontaine.document import (
-    TYPE_ACTION, TYPE_CENTEREDACTION, TYPE_CHARACTER, TYPE_DIALOG,
-    TYPE_PARENTHETICAL, TYPE_TRANSITION, TYPE_LYRICS, TYPE_PAGEBREAK)
-
-
-class BaseDocumentRenderer:
-    def __init__(self, text_renderer=None):
-        self.text_renderer = text_renderer
-        if not text_renderer:
-            self.text_renderer = NullTextRenderer()
-
-        self._para_rdrs = {
-            TYPE_ACTION: self.write_action,
-            TYPE_CENTEREDACTION: self.write_centeredaction,
-            TYPE_CHARACTER: self.write_character,
-            TYPE_DIALOG: self.write_dialog,
-            TYPE_PARENTHETICAL: self.write_parenthetical,
-            TYPE_TRANSITION: self.write_transition,
-            TYPE_LYRICS: self.write_lyrics,
-            TYPE_PAGEBREAK: self.write_pagebreak,
-        }
-
-    def _tr(self, text):
-        return self.text_renderer.render_text(text)
-
-    def render_doc(self, doc, out):
-        self.write_header(doc, out)
-        self.render_title_page(doc.title_values, out)
-        for s in doc.scenes:
-            self.render_scene(s, out)
-        self.write_footer(doc, out)
-
-    def render_title_page(self, values, out):
-        clean_values = values.copy()
-        clean_values.setdefault('title', 'Untitled Screenplay')
-        clean_values.setdefault('credit', 'Written by')
-        clean_values.setdefault('author', 'Unknown')
-        for k in clean_values:
-            clean_values[k] = self._tr(clean_values[k])
-        self.write_title_page(clean_values, out)
-
-    def render_scene(self, scene, out):
-        if scene.header is not None:
-            self.write_scene_heading(scene.header, out)
-        for p in scene.paragraphs:
-            rdr_func = self._para_rdrs[p.type]
-            if p.type != TYPE_PAGEBREAK:
-                rdr_func(self._tr(p.text), out)
-            else:
-                rdr_func(out)
-
-    def write_header(self, doc, out):
-        pass
-
-    def write_footer(self, doc, out):
-        pass
-
-    def write_title_page(self, values, out):
-        raise NotImplementedError()
-
-    def write_scene_heading(self, text, out):
-        raise NotImplementedError()
-
-    def write_action(self, text, out):
-        raise NotImplementedError()
-
-    def write_centeredaction(self, text, out):
-        raise NotImplementedError()
-
-    def write_character(self, text, out):
-        raise NotImplementedError()
-
-    def write_dialog(self, text, out):
-        raise NotImplementedError()
-
-    def write_parenthetical(self, text, out):
-        raise NotImplementedError()
-
-    def write_transition(self, text, out):
-        raise NotImplementedError()
-
-    def write_lyrics(self, text, out):
-        raise NotImplementedError()
-
-    def write_pagebreak(self, out):
-        raise NotImplementedError()
-
-
-RE_ITALICS = re.compile(
-    r"(?P<before>^|\s)(?P<esc>\\)?\*(?P<text>.*[^\s\*])\*(?=[^a-zA-Z0-9\*]|$)")
-RE_BOLD = re.compile(
-    r"(?P<before>^|\s)(?P<esc>\\)?\*\*(?P<text>.*[^\s])\*\*(?=[^a-zA-Z0-9]|$)")
-RE_UNDERLINE = re.compile(
-    r"(?P<before>^|\s)(?P<esc>\\)?_(?P<text>.*[^\s])_(?=[^a-zA-Z0-9]|$)")
-
-
-class BaseTextRenderer:
-    def render_text(self, text):
-        # Replace bold stuff to catch double asterisks.
-        text = RE_BOLD.sub(self._do_make_bold, text)
-        text = RE_ITALICS.sub(self._do_make_italics, text)
-        text = RE_UNDERLINE.sub(self._do_make_underline, text)
-
-        return text
-
-    def _do_make_italics(self, m):
-        if m.group('esc'):
-            return m.group('before') + '*' + m.group('text') + '*'
-        return (
-            m.group('before') +
-            self.make_italics(m.group('text')))
-
-    def _do_make_bold(self, m):
-        if m.group('esc'):
-            return m.group('before') + '**' + m.group('text') + '**'
-        return (
-            m.group('before') +
-            self.make_bold(m.group('text')))
-
-    def _do_make_underline(self, m):
-        if m.group('esc'):
-            return m.group('before') + '_' + m.group('text') + '_'
-        return (
-            m.group('before') +
-            self.make_underline(m.group('text')))
-
-    def make_italics(self, text):
-        raise NotImplementedError()
-
-    def make_bold(self, text):
-        raise NotImplementedError()
-
-    def make_underline(self, text):
-        raise NotImplementedError()
-
-
-class NullTextRenderer(BaseTextRenderer):
-    def make_bold(self, text):
-        return text
-
-    def make_italics(self, text):
-        return text
-
-    def make_underline(self, text):
-        return text
--- a/fontaine/resources/html_footer.html	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-        <p class="footer">Generated with <a href="https://bolt80.com/fontaine">Fontaine</a></p>
-    </body>
-</html>
--- a/fontaine/resources/html_header.html	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,40 +0,0 @@
-<!doctype html>
-<html>
-    <head>
-        <meta charset="utf-8">
-        <meta http-equiv="x-ua-compatible" content="ie=edge">
-        <title>%(title)s</title>
-        <meta name="description" content="%(description)s">
-        <meta name="viewport" content="width=device-width, initial-scale=1">
-
-        <link rel="apple-touch-icon" href="apple-touch-icon.png">
-        <!-- Place favicon.ico in the root directory -->
-
-        <style>
-        body {
-            margin: 1em 3em;
-            background-color: #666;
-        }
-        .fontaine-doc {
-            background-color: #fff;
-            padding: 2em;
-            box-shadow: #111 0px 0.5em 2em;
-        }
-        p.footer {
-            text-align: center;
-            text-transform: uppercase;
-            font-size: 0.9em;
-            font-family: "Times New Roman", "Times", serif;
-            color: #333;
-            padding: 1em;
-        }
-        p.footer a:link, p.footer a:visited, p.footer a:active {
-            color: #222;
-        }
-        p.footer a:hover {
-            color: #227;
-        }
-        </style>
-        <style>%(css)s</style>
-    </head>
-    <body>
--- a/fontaine/resources/html_styles.css	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-.fontaine-doc {
-  font-family: "Courier Prime", "Courier New", Courier, sans-serif; }
-  .fontaine-doc h1, .fontaine-doc .fontaine-title-page-heading {
-    text-align: center; }
-  .fontaine-doc .fontaine-scene-heading {
-    font-weight: bold; }
-  .fontaine-doc .fontaine-character {
-    margin: auto 12rem 0 12rem; }
-  .fontaine-doc .fontaine-parenthetical {
-    margin: 0 8rem 0 8rem; }
-  .fontaine-doc .fontaine-dialog {
-    margin: 0 4rem 0 4rem; }
-  .fontaine-doc .fontaine-action-centered {
-    text-align: center; }
-
-/*# sourceMappingURL=html_styles.css.map */
--- a/fontaine/resources/html_styles.scss	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,30 +0,0 @@
-.fontaine-doc {
-    font-family: "Courier Prime", "Courier New", Courier, sans-serif;
-
-    h1, .fontaine-title-page-heading {
-        text-align: center;
-    }
-
-    .fontaine-scene-heading {
-        font-weight: bold;
-    }
-
-    .fontaine-transition {
-    }
-
-    .fontaine-character {
-        margin: auto 3 * 4rem 0 3 * 4rem;
-    }
-
-    .fontaine-parenthetical {
-        margin: 0 2 * 4rem 0 2 * 4rem;
-    }
-
-    .fontaine-dialog {
-        margin: 0 4rem 0 4rem;
-    }
-
-    .fontaine-action-centered {
-        text-align: center;
-    }
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/__init__.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,4 @@
+try:
+    from .version import version
+except ImportError:
+    version = '<unknown>'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/cli.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,24 @@
+import sys
+import argparse
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description='Jouvence command line utility')
+    parser.add_argument('script')
+    parser.add_argument('out_file', nargs='?')
+    args = parser.parse_args()
+
+    from jouvence.parser import JouvenceParser
+    p = JouvenceParser()
+    doc = p.parse(args.script)
+
+    if not args.out_file:
+        from jouvence.console import ConsoleDocumentRenderer
+        rdr = ConsoleDocumentRenderer()
+        rdr.render_doc(doc, sys.stdout)
+    else:
+        from jouvence.html import HtmlDocumentRenderer
+        rdr = HtmlDocumentRenderer()
+        with open(args.out_file, 'w') as fp:
+            rdr.render_doc(doc, fp)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/console.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,93 @@
+import os
+import colorama
+from .renderer import BaseDocumentRenderer, BaseTextRenderer
+
+
+def _w(out, style, text, reset_all=False):
+    f = out.write
+    f(style)
+    f(text)
+    if not reset_all:
+        f(colorama.Style.NORMAL)
+    else:
+        f(colorama.Style.RESET_ALL)
+    f(os.linesep)
+
+
+class ConsoleDocumentRenderer(BaseDocumentRenderer):
+    def __init__(self, width=80):
+        super().__init__(ConsoleTextRenderer())
+        self.width = width
+        colorama.init()
+
+    def write_title_page(self, values, out):
+        known = ['title', 'credit', 'author', 'source']
+        center_values = [values.get(i) for i in known
+                         if i is not None]
+
+        print("", file=out)
+        for val in center_values:
+            for l in val.split('\n'):
+                print(l.center(self.width), file=out)
+            print("", file=out)
+        print("", file=out)
+        print("", file=out)
+
+        ddate = values.get('date') or values.get('draft date')
+        contact = values.get('contact')
+        bottom_lines = [i for i in [ddate, contact]
+                        if i is not None]
+
+        _w(out, colorama.Style.DIM, '\n\n'.join(bottom_lines))
+        print("", file=out)
+        _w(out, colorama.Style.DIM, 80 * '=')
+
+    def write_scene_heading(self, text, out):
+        print("", file=out)
+        _w(out, colorama.Fore.WHITE + colorama.Style.BRIGHT, text, True)
+
+    def write_action(self, text, out):
+        print(text, file=out)
+
+    def write_centeredaction(self, text, out):
+        print("", file=out)
+        for line in text.split('\n'):
+            print(line.center(self.width), file=out)
+
+    def write_character(self, text, out):
+        print("", file=out)
+        _w(out, colorama.Fore.WHITE, "\t\t\t" + text, True)
+
+    def write_dialog(self, text, out):
+        for line in text.split('\n'):
+            print("\t" + line, file=out)
+
+    def write_parenthetical(self, text, out):
+        for line in text.split('\n'):
+            print("\t\t" + line, file=out)
+
+    def write_transition(self, text, out):
+        print("", file=out)
+        print("\t\t\t\t" + text, file=out)
+
+    def write_lyrics(self, text, out):
+        print("", file=out)
+        _w(out, colorama.Fore.MAGENTA, text, True)
+
+    def write_pagebreak(self, out):
+        print("", file=out)
+        _w(out, colorama.Style.DIM, 80 * '=')
+
+
+class ConsoleTextRenderer(BaseTextRenderer):
+    def _writeStyled(self, style, text):
+        return style + text + colorama.Style.NORMAL
+
+    def make_italics(self, text):
+        return self._writeStyled(colorama.Style.BRIGHT, text)
+
+    def make_bold(self, text):
+        return self._writeStyled(colorama.Style.BRIGHT, text)
+
+    def make_underline(self, text):
+        return self._writeStyled(colorama.Style.BRIGHT, text)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/document.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,116 @@
+import sys
+
+
+class JouvenceDocument:
+    def __init__(self):
+        self.title_values = {}
+        self.scenes = []
+
+    def addScene(self, header=None):
+        s = JouvenceScene()
+        if header:
+            s.header = header
+        self.scenes.append(s)
+        return s
+
+    def lastScene(self, auto_create=True):
+        try:
+            return self.scenes[-1]
+        except IndexError:
+            if auto_create:
+                s = self.addScene()
+                return s
+            return None
+
+    def lastParagraph(self):
+        s = self.lastScene(False)
+        if s:
+            return s.lastParagraph()
+        return None
+
+
+class JouvenceScene:
+    def __init__(self):
+        self.header = None
+        self.paragraphs = []
+        self._adders = {}
+
+    def __getattr__(self, name):
+        if name.startswith('add'):
+            add_type_name = name[3:]
+            try:
+                adder = self._adders[add_type_name]
+            except KeyError:
+                module = sys.modules[__name__]
+                add_type = getattr(module,
+                                   'TYPE_%s' % add_type_name.upper())
+
+                def _type_adder(_text):
+                    new_p = JouvenceSceneElement(add_type, _text)
+                    self.paragraphs.append(new_p)
+                    return new_p
+
+                adder = _type_adder
+                self._adders[add_type_name] = adder
+            return adder
+        else:
+            raise AttributeError
+
+    def addPageBreak(self):
+        self.paragraphs.append(JouvenceSceneElement(TYPE_PAGEBREAK, None))
+
+    def lastParagraph(self):
+        try:
+            return self.paragraphs[-1]
+        except IndexError:
+            return None
+
+
+class JouvenceSceneElement:
+    def __init__(self, el_type, text):
+        self.type = el_type
+        self.text = text
+
+    def __str__(self):
+        return '%s: %s' % (
+            _scene_element_type_str(self.type),
+            _ellipsis(self.text, 15))
+
+
+TYPE_ACTION = 0
+TYPE_CENTEREDACTION = 1
+TYPE_CHARACTER = 2
+TYPE_DIALOG = 3
+TYPE_PARENTHETICAL = 4
+TYPE_TRANSITION = 5
+TYPE_LYRICS = 6
+TYPE_PAGEBREAK = 7
+TYPE_EMPTYLINES = 8
+
+
+def _scene_element_type_str(t):
+    if t == TYPE_ACTION:
+        return 'ACTION'
+    if t == TYPE_CENTEREDACTION:
+        return 'CENTEREDACTION'
+    if t == TYPE_CHARACTER:
+        return 'CHARACTER'
+    if t == TYPE_DIALOG:
+        return 'DIALOG'
+    if t == TYPE_PARENTHETICAL:
+        return 'PARENTHETICAL'
+    if t == TYPE_TRANSITION:
+        return 'TRANSITION'
+    if t == TYPE_LYRICS:
+        return 'LYRICS'
+    if t == TYPE_PAGEBREAK:
+        return 'PAGEBREAK'
+    if t == TYPE_EMPTYLINES:
+        return 'EMPTYLINES'
+    raise NotImplementedError()
+
+
+def _ellipsis(text, length):
+    if len(text) > length:
+        return text[:length - 3] + '...'
+    return text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/html.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,110 @@
+import os.path
+from markupsafe import escape
+from .renderer import BaseDocumentRenderer, BaseTextRenderer
+
+
+def _elem(out, elem_name, class_name, contents):
+    f = out.write
+    f('<%s' % elem_name)
+    if class_name:
+        f(' class="jouvence-%s"' % class_name)
+    f('>')
+    f(contents)
+    f('</%s>\n' % elem_name)
+
+
+def _br(text, strip_first=False):
+    lines = text.split('\n')
+    if strip_first and lines[0].strip() == '':
+        lines = lines[1:]
+    return '<br/>\n'.join(lines)
+
+
+def _res(filename):
+    path = os.path.join(os.path.dirname(__file__), 'resources', filename)
+    with open(path, 'r') as fp:
+        return fp.read()
+
+
+class HtmlDocumentRenderer(BaseDocumentRenderer):
+    def __init__(self, standalone=True):
+        super().__init__(HtmlTextRenderer())
+        self.standalone = standalone
+
+    def get_css(self):
+        return _res('html_styles.css')
+
+    def write_header(self, doc, out):
+        if self.standalone:
+            meta = doc.title_values.get
+            data = {
+                # TODO: need a "strip formatting" to have a clean title.
+                'title': meta('title', "Fountain Screenplay"),
+                'description': meta('description', ''),
+                'css': self.get_css()
+            }
+            out.write(_res('html_header.html') % data)
+        out.write('<div class="jouvence-doc">\n')
+
+    def write_footer(self, doc, out):
+        out.write('</div>\n')
+        if self.standalone:
+            out.write(_res('html_footer.html'))
+
+    def write_title_page(self, values, out):
+        out.write('<div class="jouvence-title-page">\n')
+
+        _elem(out, 'h1', None, _br(values['title']))
+        _elem(out, 'p', 'title-page-heading', _br(values['credit']))
+        _elem(out, 'p', 'title-page-heading', _br(values['author']))
+
+        ddate = values.get('date') or values.get('draft date')
+        if ddate:
+            _elem(out, 'p', 'title-page-footer', _br(ddate))
+        contact = values.get('contact')
+        if contact:
+            _elem(out, 'p', 'title-page-footer', _br(contact))
+
+        out.write('</div>\n')
+        self.write_pagebreak(out)
+
+    def write_scene_heading(self, text, out):
+        _elem(out, 'p', 'scene-heading', text)
+
+    def write_action(self, text, out):
+        _elem(out, 'p', 'action', _br(text, True))
+
+    def write_centeredaction(self, text, out):
+        _elem(out, 'p', 'action-centered', _br(text, True))
+
+    def write_character(self, text, out):
+        _elem(out, 'p', 'character', text)
+
+    def write_dialog(self, text, out):
+        _elem(out, 'p', 'dialog', _br(text))
+
+    def write_parenthetical(self, text, out):
+        _elem(out, 'p', 'parenthetical', text)
+
+    def write_transition(self, text, out):
+        _elem(out, 'p', 'transition', text)
+
+    def write_lyrics(self, text, out):
+        _elem(out, 'p', 'lyrics', _br(text, True))
+
+    def write_pagebreak(self, out):
+        out.write('<hr/>\n')
+
+
+class HtmlTextRenderer(BaseTextRenderer):
+    def render_text(self, text):
+        return super().render_text(escape(text))
+
+    def make_italics(self, text):
+        return '<em>%s</em>' % text
+
+    def make_bold(self, text):
+        return '<strong>%s</strong>' % text
+
+    def make_underline(self, text):
+        return '<u>%s</u>' % text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/parser.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,589 @@
+import re
+import logging
+from .document import TYPE_ACTION
+
+
+logger = logging.getLogger(__name__)
+
+
+class JouvenceState:
+    def __init__(self):
+        pass
+
+    def match(self, fp, ctx):
+        return False
+
+    def consume(self, fp, ctx):
+        raise NotImplementedError()
+
+    def exit(self, ctx, next_state):
+        pass
+
+
+class _PassThroughState(JouvenceState):
+    def consume(self, fp, ctx):
+        return ANY_STATE
+
+
+class JouvenceParserError(Exception):
+    def __init__(self, line_no, message):
+        super().__init__("Error line %d: %s" % (line_no, message))
+
+
+ANY_STATE = object()
+EOF_STATE = object()
+
+
+RE_EMPTY_LINE = re.compile(r"^$", re.M)
+RE_BLANK_LINE = re.compile(r"^\s*$", re.M)
+
+RE_TITLE_KEY_VALUE = re.compile(r"^(?P<key>[\w\s\-]+)\s*:\s*")
+
+
+class _TitlePageState(JouvenceState):
+    def __init__(self):
+        super().__init__()
+        self._cur_key = None
+        self._cur_val = None
+
+    def match(self, fp, ctx):
+        line = fp.peekline()
+        return RE_TITLE_KEY_VALUE.match(line)
+
+    def consume(self, fp, ctx):
+        while True:
+            line = fp.readline()
+            if not line:
+                return EOF_STATE
+
+            m = RE_TITLE_KEY_VALUE.match(line)
+            if m:
+                # Commit current value, start new one.
+                self._commit(ctx)
+                self._cur_key = m.group('key').lower()
+                self._cur_val = line[m.end():]
+            else:
+                # Keep accumulating the value of one of the title page's
+                # values.
+                self._cur_val += line.lstrip()
+
+            if RE_EMPTY_LINE.match(fp.peekline()):
+                self._commit(ctx)
+                # Finished with the page title, now move on to the first scene.
+                break
+
+        return ANY_STATE
+
+    def exit(self, ctx, next_state):
+        self._commit(ctx)
+
+    def _commit(self, ctx):
+        if self._cur_key is not None:
+            val = self._cur_val.rstrip('\r\n')
+            ctx.document.title_values[self._cur_key] = val
+            self._cur_key = None
+            self._cur_val = None
+
+
+RE_SCENE_HEADER_PATTERN = re.compile(
+    r"^(int|ext|est|int/ext|int./ext|i/e)[\s\.]", re.I)
+
+
+class _SceneHeaderState(JouvenceState):
+    def match(self, fp, ctx):
+        lines = fp.peeklines(3)
+        return (
+            RE_EMPTY_LINE.match(lines[0]) and
+            RE_SCENE_HEADER_PATTERN.match(lines[1]) and
+            RE_EMPTY_LINE.match(lines[2]))
+
+    def consume(self, fp, ctx):
+        fp.readline()  # Get past the blank line.
+        line = fp.readline().rstrip('\r\n')
+        line = line.lstrip('.')  # In case it was forced.
+        ctx.document.addScene(line)
+        return ANY_STATE
+
+
+class _ActionState(JouvenceState):
+    def __init__(self):
+        super().__init__()
+        self.text = ''
+
+    def match(self, fp, ctx):
+        return True
+
+    def consume(self, fp, ctx):
+        is_first_line = True
+        while True:
+            line = fp.readline()
+            if not line:
+                return EOF_STATE
+
+            if is_first_line:
+                # Ignore the fake blank line at 0 if it's threre.
+                if fp.line_no == 0:
+                    continue
+
+                line = line.lstrip('!')  # In case it was forced.
+                is_first_line = False
+
+            # If the next line is empty, strip the carriage return from
+            # the line we just got because it's probably gonna be the
+            # last one.
+            if RE_EMPTY_LINE.match(fp.peekline()):
+                self.text += line.rstrip("\r\n")
+                break
+            # ...otherwise, add the line with in full.
+            self.text += line
+
+        return ANY_STATE
+
+    def exit(self, ctx, next_state):
+        last_para = ctx.document.lastParagraph()
+        if last_para and last_para.type == TYPE_ACTION:
+            last_para.text += '\n' + self.text
+        else:
+            ctx.document.lastScene().addAction(self.text)
+
+
+RE_CENTERED_LINE = re.compile(r"^\s*>\s*.*\s*<\s*$", re.M)
+
+
+class _CenteredActionState(JouvenceState):
+    def __init__(self):
+        super().__init__()
+        self.text = ''
+        self._aborted = False
+
+    def match(self, fp, ctx):
+        lines = fp.peeklines(2)
+        return (
+            RE_EMPTY_LINE.match(lines[0]) and
+            RE_CENTERED_LINE.match(lines[1]))
+
+    def consume(self, fp, ctx):
+        snapshot = fp.snapshot()
+        fp.readline()  # Get past the empty line.
+        while True:
+            line = fp.readline()
+            if not line:
+                return EOF_STATE
+
+            clean_line = line.rstrip('\r\n')
+            eol = line[len(clean_line):]
+
+            clean_line = clean_line.strip()
+            if clean_line[0] != '>' or clean_line[-1] != '<':
+                # The whole paragraph must have `>` and `<` wrappers, so
+                # if we detect a line that doesn't have them, we make this
+                # paragraph be a normal action instead.
+                fp.restore(snapshot)
+                self._aborted = True
+                return _ActionState()
+            else:
+                # Remove wrapping `>`/`<`, and spaces.
+                clean_line = clean_line[1:-1].strip()
+
+            if RE_EMPTY_LINE.match(fp.peekline()):
+                self.text += clean_line
+                break
+            self.text += clean_line + eol
+
+        return ANY_STATE
+
+    def exit(self, ctx, next_state):
+        if not self._aborted:
+            ctx.document.lastScene().addCenteredAction(self.text)
+
+
+RE_CHARACTER_LINE = re.compile(r"^\s*[A-Z][A-Z\-\._\s]+\s*(\(.*\))?$", re.M)
+
+
+class _CharacterState(JouvenceState):
+    def match(self, fp, ctx):
+        lines = fp.peeklines(3)
+        return (RE_EMPTY_LINE.match(lines[0]) and
+                RE_CHARACTER_LINE.match(lines[1]) and
+                not RE_EMPTY_LINE.match(lines[2]))
+
+    def consume(self, fp, ctx):
+        fp.readline()  # Get past the empty line.
+        line = fp.readline().rstrip('\r\n')
+        line = line.lstrip()  # Remove indenting.
+        line = line.lstrip('@')  # In case it was forced.
+        ctx.document.lastScene().addCharacter(line)
+        return [_ParentheticalState, _DialogState]
+
+
+RE_PARENTHETICAL_LINE = re.compile(r"^\s*\(.*\)\s*$", re.M)
+
+
+class _ParentheticalState(JouvenceState):
+    def match(self, fp, ctx):
+        # We only get here from a `_CharacterState` so we know the previous
+        # one is already that.
+        line = fp.peekline()
+        return RE_PARENTHETICAL_LINE.match(line)
+
+    def consume(self, fp, ctx):
+        line = fp.readline().lstrip().rstrip('\r\n')
+        ctx.document.lastScene().addParenthetical(line)
+
+        next_line = fp.peekline()
+        if not RE_EMPTY_LINE.match(next_line):
+            return _DialogState()
+
+        return ANY_STATE
+
+
+class _DialogState(JouvenceState):
+    def __init__(self):
+        super().__init__()
+        self.text = ''
+
+    def match(self, fp, ctx):
+        # We only get here from a `_CharacterState` or `_ParentheticalState`
+        # so we just need to check there's some text.
+        line = fp.peekline()
+        return not RE_EMPTY_LINE.match(line)
+
+    def consume(self, fp, ctx):
+        while True:
+            line = fp.readline()
+            if not line:
+                return EOF_STATE
+
+            line = line.lstrip()  # Remove indenting.
+
+            # Next we could be either continuing the dialog line, going to
+            # a parenthetical, or exiting dialog altogether.
+            next_line = fp.peekline()
+
+            if RE_PARENTHETICAL_LINE.match(next_line):
+                self.text += line.rstrip('\r\n')
+                return _ParentheticalState()
+
+            if RE_EMPTY_LINE.match(next_line):
+                self.text += line.rstrip('\r\n')
+                break
+            self.text += line
+
+        return ANY_STATE
+
+    def exit(self, ctx, next_state):
+        ctx.document.lastScene().addDialog(self.text.rstrip('\r\n'))
+
+
+class _LyricsState(JouvenceState):
+    def __init__(self):
+        super().__init__()
+        self.text = ''
+        self._aborted = False
+
+    # No `match` method, this can only be forced.
+    # (see `_ForcedParagraphStates`)
+
+    def consume(self, fp, ctx):
+        snapshot = fp.snapshot()
+        fp.readline()  # Get past the empty line.
+        while True:
+            line = fp.readline()
+            if not line:
+                return EOF_STATE
+
+            if line.startswith('~'):
+                line = line.lstrip('~')
+            else:
+                logger.debug("Rolling back lyrics into action paragraph.")
+                fp.restore(snapshot)
+                self._aborted = True
+                return _ActionState()
+
+            if RE_EMPTY_LINE.match(fp.peekline()):
+                self.text += line.rstrip('\r\n')
+                break
+            self.text += line
+
+        return ANY_STATE
+
+    def exit(self, ctx, next_state):
+        if not self._aborted:
+            ctx.document.lastScene().addLyrics(self.text)
+
+
+RE_TRANSITION_LINE = re.compile(r"^\s*[^a-z]+TO\:$", re.M)
+
+
+class _TransitionState(JouvenceState):
+    def match(self, fp, ctx):
+        lines = fp.peeklines(3)
+        return (
+            RE_EMPTY_LINE.match(lines[0]) and
+            RE_TRANSITION_LINE.match(lines[1]) and
+            RE_EMPTY_LINE.match(lines[2]))
+
+    def consume(self, fp, ctx):
+        fp.readline()  # Get past the empty line.
+        line = fp.readline().lstrip().rstrip('\r\n')
+        line = line.lstrip('>')  # In case it was forced.
+        ctx.document.lastScene().addTransition(line)
+        return ANY_STATE
+
+
+RE_PAGE_BREAK_LINE = re.compile(r"^\=\=\=+$", re.M)
+
+
+class _PageBreakState(JouvenceState):
+    def match(self, fp, ctx):
+        lines = fp.peeklines(3)
+        return (
+            RE_EMPTY_LINE.match(lines[0]) and
+            RE_PAGE_BREAK_LINE.match(lines[1]) and
+            RE_EMPTY_LINE.match(lines[2]))
+
+    def consume(self, fp, ctx):
+        fp.readline()
+        fp.readline()
+        ctx.document.lastScene().addPageBreak()
+        return ANY_STATE
+
+
+class _ForcedParagraphStates(JouvenceState):
+    STATE_SYMBOLS = {
+        '.': _SceneHeaderState,
+        '!': _ActionState,
+        '@': _CharacterState,
+        '~': _LyricsState,
+        '>': _TransitionState
+    }
+
+    def __init__(self):
+        super().__init__()
+        self._state_cls = None
+        self._consume_empty_line = False
+
+    def match(self, fp, ctx):
+        lines = fp.peeklines(2)
+        symbol = lines[1][:1]
+        if (RE_EMPTY_LINE.match(lines[0]) and
+                symbol in self.STATE_SYMBOLS):
+            # Special case: don't force a transition state if it's
+            # really some centered text.
+            if symbol == '>' and RE_CENTERED_LINE.match(lines[1]):
+                return False
+
+            self._state_cls = self.STATE_SYMBOLS[symbol]
+
+            # Special case: for forced action paragraphs, don't leave
+            # the blank line there.
+            if symbol == '!':
+                self._consume_empty_line = True
+
+            return True
+        return False
+
+    def consume(self, fp, ctx):
+        if self._consume_empty_line:
+            fp.readline()
+        return self._state_cls()
+
+
+class _EmptyLineState(JouvenceState):
+    def __init__(self):
+        super().__init__()
+        self.line_count = 0
+
+    def match(self, fp, ctx):
+        return RE_EMPTY_LINE.match(fp.peekline())
+
+    def consume(self, fp, ctx):
+        fp.readline()
+        if fp.line_no > 1:  # Don't take into account the fake blank at 0
+            self.line_count += 1
+        return ANY_STATE
+
+    def exit(self, ctx, next_state):
+        if self.line_count > 0:
+            text = self.line_count * '\n'
+            last_para = ctx.document.lastParagraph()
+            if last_para and last_para.type == TYPE_ACTION:
+                last_para.text += text
+            else:
+                ctx.document.lastScene().addAction(text[1:])
+
+
+ROOT_STATES = [
+    _ForcedParagraphStates,  # Must be first.
+    _SceneHeaderState,
+    _CharacterState,
+    _TransitionState,
+    _PageBreakState,
+    _CenteredActionState,
+    _EmptyLineState,   # Must be second to last.
+    _ActionState,  # Must be last.
+]
+
+
+class _PeekableFile:
+    def __init__(self, fp):
+        self.line_no = 1
+        self._fp = fp
+        self._blankAt0 = False
+
+    def readline(self):
+        if self._blankAt0:
+            self._blankAt0 = False
+            self.line_no = 0
+            return '\n'
+
+        data = self._fp.readline()
+        self.line_no += 1
+        return data
+
+    def peekline(self):
+        if self._blankAt0:
+            return '\n'
+
+        pos = self._fp.tell()
+        line = self._fp.readline()
+        self._fp.seek(pos)
+        return line
+
+    def peeklines(self, count):
+        pos = self._fp.tell()
+        lines = []
+        if self._blankAt0:
+            lines.append('\n')
+            count -= 1
+        for i in range(count):
+            lines.append(self._fp.readline())
+        self._fp.seek(pos)
+        return lines
+
+    def snapshot(self):
+        return (self._fp.tell(), self._blankAt0, self.line_no)
+
+    def restore(self, snapshot):
+        self._fp.seek(snapshot[0])
+        self._blankAt0 = snapshot[1]
+        self.line_no = snapshot[2]
+
+    def _addBlankAt0(self):
+        if self._fp.tell() != 0:
+            raise Exception(
+                "Can't add blank line at 0 if reading has started.")
+        self._blankAt0 = True
+        self.line_no = 0
+
+
+class _JouvenceStateMachine:
+    def __init__(self, fp, doc):
+        self.fp = _PeekableFile(fp)
+        self.state = None
+        self.document = doc
+
+    @property
+    def line_no(self):
+        return self.fp.line_no
+
+    def run(self):
+        # Start with the page title... unless it doesn't match, in which
+        # case we start with a "pass through" state that will just return
+        # `ANY_STATE` so we can start matching stuff.
+        self.state = _TitlePageState()
+        if not self.state.match(self.fp, self):
+            logger.debug("No title page value found on line 1, "
+                         "using pass-through state with added blank line.")
+            self.state = _PassThroughState()
+            if not RE_EMPTY_LINE.match(self.fp.peekline()):
+                # Add a fake empty line at the beginning of the text if
+                # there's not one already. This makes state matching easier.
+                self.fp._addBlankAt0()
+
+        # Start parsing! Here we try to do a mostly-forward-only parser with
+        # non overlapping regexes to make it decently fast.
+        while True:
+            logger.debug("State '%s' consuming from '%s'..." %
+                         (self.state.__class__.__name__, self.fp.peekline()))
+            res = self.state.consume(self.fp, self)
+
+            # See if we reached the end of the file.
+            if not self.fp.peekline():
+                logger.debug("Reached end of line... ending parsing.")
+                res = EOF_STATE
+
+            # Figure out what to do next...
+
+            if res is None:
+                raise JouvenceParserError(
+                    self.line_no,
+                    "State '%s' returned a `None` result. "
+                    "States need to return `ANY_STATE`, one or more specific "
+                    "states, or `EOF_STATE` if they reached the end of the "
+                    "file." % self.state.__class__.__name__)
+
+            elif res is ANY_STATE or isinstance(res, list):
+                # State wants to exit, we need to figure out what is the
+                # next state.
+                pos = self.fp._fp.tell()
+                next_states = res
+                if next_states is ANY_STATE:
+                    next_states = ROOT_STATES
+                logger.debug("Trying to match next state from: %s" %
+                             [t.__name__ for t in next_states])
+                for sc in next_states:
+                    s = sc()
+                    if s.match(self.fp, self):
+                        logger.debug("Matched state %s" %
+                                     s.__class__.__name__)
+                        self.fp._fp.seek(pos)
+                        res = s
+                        break
+                else:
+                    raise Exception("Can't match following state after: %s" %
+                                    self.state)
+
+                # Handle the current state before we move on to the new one.
+                if self.state:
+                    self.state.exit(self, res)
+                self.state = res
+
+            elif isinstance(res, JouvenceState):
+                # State wants to exit, wants a specific state to be next.
+                if self.state:
+                    self.state.exit(self, res)
+                self.state = res
+
+            elif res is EOF_STATE:
+                # Reached end of file.
+                if self.state:
+                    self.state.exit(self, res)
+                break
+
+            else:
+                raise Exception("Unsupported state result: %s" % res)
+
+
+class JouvenceParser:
+    def __init__(self):
+        pass
+
+    def parse(self, filein):
+        if isinstance(filein, str):
+            with open(filein, 'r') as fp:
+                return self._doParse(fp)
+        else:
+            return self._doParse(fp)
+
+    def parseString(self, text):
+        import io
+        with io.StringIO(text) as fp:
+            return self._doParse(fp)
+
+    def _doParse(self, fp):
+        from .document import JouvenceDocument
+        doc = JouvenceDocument()
+        machine = _JouvenceStateMachine(fp, doc)
+        machine.run()
+        return doc
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/renderer.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,146 @@
+import re
+from jouvence.document import (
+    TYPE_ACTION, TYPE_CENTEREDACTION, TYPE_CHARACTER, TYPE_DIALOG,
+    TYPE_PARENTHETICAL, TYPE_TRANSITION, TYPE_LYRICS, TYPE_PAGEBREAK)
+
+
+class BaseDocumentRenderer:
+    def __init__(self, text_renderer=None):
+        self.text_renderer = text_renderer
+        if not text_renderer:
+            self.text_renderer = NullTextRenderer()
+
+        self._para_rdrs = {
+            TYPE_ACTION: self.write_action,
+            TYPE_CENTEREDACTION: self.write_centeredaction,
+            TYPE_CHARACTER: self.write_character,
+            TYPE_DIALOG: self.write_dialog,
+            TYPE_PARENTHETICAL: self.write_parenthetical,
+            TYPE_TRANSITION: self.write_transition,
+            TYPE_LYRICS: self.write_lyrics,
+            TYPE_PAGEBREAK: self.write_pagebreak,
+        }
+
+    def _tr(self, text):
+        return self.text_renderer.render_text(text)
+
+    def render_doc(self, doc, out):
+        self.write_header(doc, out)
+        self.render_title_page(doc.title_values, out)
+        for s in doc.scenes:
+            self.render_scene(s, out)
+        self.write_footer(doc, out)
+
+    def render_title_page(self, values, out):
+        clean_values = values.copy()
+        clean_values.setdefault('title', 'Untitled Screenplay')
+        clean_values.setdefault('credit', 'Written by')
+        clean_values.setdefault('author', 'Unknown')
+        for k in clean_values:
+            clean_values[k] = self._tr(clean_values[k])
+        self.write_title_page(clean_values, out)
+
+    def render_scene(self, scene, out):
+        if scene.header is not None:
+            self.write_scene_heading(scene.header, out)
+        for p in scene.paragraphs:
+            rdr_func = self._para_rdrs[p.type]
+            if p.type != TYPE_PAGEBREAK:
+                rdr_func(self._tr(p.text), out)
+            else:
+                rdr_func(out)
+
+    def write_header(self, doc, out):
+        pass
+
+    def write_footer(self, doc, out):
+        pass
+
+    def write_title_page(self, values, out):
+        raise NotImplementedError()
+
+    def write_scene_heading(self, text, out):
+        raise NotImplementedError()
+
+    def write_action(self, text, out):
+        raise NotImplementedError()
+
+    def write_centeredaction(self, text, out):
+        raise NotImplementedError()
+
+    def write_character(self, text, out):
+        raise NotImplementedError()
+
+    def write_dialog(self, text, out):
+        raise NotImplementedError()
+
+    def write_parenthetical(self, text, out):
+        raise NotImplementedError()
+
+    def write_transition(self, text, out):
+        raise NotImplementedError()
+
+    def write_lyrics(self, text, out):
+        raise NotImplementedError()
+
+    def write_pagebreak(self, out):
+        raise NotImplementedError()
+
+
+RE_ITALICS = re.compile(
+    r"(?P<before>^|\s)(?P<esc>\\)?\*(?P<text>.*[^\s\*])\*(?=[^a-zA-Z0-9\*]|$)")
+RE_BOLD = re.compile(
+    r"(?P<before>^|\s)(?P<esc>\\)?\*\*(?P<text>.*[^\s])\*\*(?=[^a-zA-Z0-9]|$)")
+RE_UNDERLINE = re.compile(
+    r"(?P<before>^|\s)(?P<esc>\\)?_(?P<text>.*[^\s])_(?=[^a-zA-Z0-9]|$)")
+
+
+class BaseTextRenderer:
+    def render_text(self, text):
+        # Replace bold stuff to catch double asterisks.
+        text = RE_BOLD.sub(self._do_make_bold, text)
+        text = RE_ITALICS.sub(self._do_make_italics, text)
+        text = RE_UNDERLINE.sub(self._do_make_underline, text)
+
+        return text
+
+    def _do_make_italics(self, m):
+        if m.group('esc'):
+            return m.group('before') + '*' + m.group('text') + '*'
+        return (
+            m.group('before') +
+            self.make_italics(m.group('text')))
+
+    def _do_make_bold(self, m):
+        if m.group('esc'):
+            return m.group('before') + '**' + m.group('text') + '**'
+        return (
+            m.group('before') +
+            self.make_bold(m.group('text')))
+
+    def _do_make_underline(self, m):
+        if m.group('esc'):
+            return m.group('before') + '_' + m.group('text') + '_'
+        return (
+            m.group('before') +
+            self.make_underline(m.group('text')))
+
+    def make_italics(self, text):
+        raise NotImplementedError()
+
+    def make_bold(self, text):
+        raise NotImplementedError()
+
+    def make_underline(self, text):
+        raise NotImplementedError()
+
+
+class NullTextRenderer(BaseTextRenderer):
+    def make_bold(self, text):
+        return text
+
+    def make_italics(self, text):
+        return text
+
+    def make_underline(self, text):
+        return text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/resources/html_footer.html	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,3 @@
+        <p class="footer">Generated with <a href="https://bolt80.com/jouvence">Jouvence</a></p>
+    </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/resources/html_header.html	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,40 @@
+<!doctype html>
+<html>
+    <head>
+        <meta charset="utf-8">
+        <meta http-equiv="x-ua-compatible" content="ie=edge">
+        <title>%(title)s</title>
+        <meta name="description" content="%(description)s">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+
+        <link rel="apple-touch-icon" href="apple-touch-icon.png">
+        <!-- Place favicon.ico in the root directory -->
+
+        <style>
+        body {
+            margin: 1em 3em;
+            background-color: #666;
+        }
+        .jouvence-doc {
+            background-color: #fff;
+            padding: 2em;
+            box-shadow: #111 0px 0.5em 2em;
+        }
+        p.footer {
+            text-align: center;
+            text-transform: uppercase;
+            font-size: 0.9em;
+            font-family: "Times New Roman", "Times", serif;
+            color: #333;
+            padding: 1em;
+        }
+        p.footer a:link, p.footer a:visited, p.footer a:active {
+            color: #222;
+        }
+        p.footer a:hover {
+            color: #227;
+        }
+        </style>
+        <style>%(css)s</style>
+    </head>
+    <body>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/resources/html_styles.css	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,16 @@
+.jouvence-doc {
+  font-family: "Courier Prime", "Courier New", Courier, sans-serif; }
+  .jouvence-doc h1, .jouvence-doc .jouvence-title-page-heading {
+    text-align: center; }
+  .jouvence-doc .jouvence-scene-heading {
+    font-weight: bold; }
+  .jouvence-doc .jouvence-character {
+    margin: auto 12rem 0 12rem; }
+  .jouvence-doc .jouvence-parenthetical {
+    margin: 0 8rem 0 8rem; }
+  .jouvence-doc .jouvence-dialog {
+    margin: 0 4rem 0 4rem; }
+  .jouvence-doc .jouvence-action-centered {
+    text-align: center; }
+
+/*# sourceMappingURL=html_styles.css.map */
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/resources/html_styles.scss	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,30 @@
+.jouvence-doc {
+    font-family: "Courier Prime", "Courier New", Courier, sans-serif;
+
+    h1, .jouvence-title-page-heading {
+        text-align: center;
+    }
+
+    .jouvence-scene-heading {
+        font-weight: bold;
+    }
+
+    .jouvence-transition {
+    }
+
+    .jouvence-character {
+        margin: auto 3 * 4rem 0 3 * 4rem;
+    }
+
+    .jouvence-parenthetical {
+        margin: 0 2 * 4rem 0 2 * 4rem;
+    }
+
+    .jouvence-dialog {
+        margin: 0 4rem 0 4rem;
+    }
+
+    .jouvence-action-centered {
+        text-align: center;
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/jouvence/version.py	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,4 @@
+# coding: utf-8
+# file generated by setuptools_scm
+# don't change, don't track in version control
+version = '0.1.0'
--- a/scripts/fontaine	Wed Jan 04 08:51:32 2017 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-#!/usr/bin/env python
-import os.path
-import sys
-
-
-sys.path.append(os.path.dirname(os.path.dirname(__file__)))
-
-
-if __name__ == '__main__':
-    from fontaine.cli import main
-    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/jouvence	Wed Jan 04 09:02:29 2017 -0800
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+import os.path
+import sys
+
+
+sys.path.append(os.path.dirname(os.path.dirname(__file__)))
+
+
+if __name__ == '__main__':
+    from jouvence.cli import main
+    main()
--- a/setup.py	Wed Jan 04 08:51:32 2017 -0800
+++ b/setup.py	Wed Jan 04 09:02:29 2017 -0800
@@ -20,14 +20,14 @@
 
 
 setup(
-    name="Fontaine",
-    use_scm_version={'write_to': 'fontaine/version.py'},
+    name="Jouvence",
+    use_scm_version={'write_to': 'jouvence/version.py'},
     description="A library for parsing and rendering Fountain screenplays.",
     long_description=long_description,
     author="Ludovic Chabant",
     author_email="ludovic@chabant.com",
     license="Apache License 2.0",
-    url="https://bolt80.com/fontaine",
+    url="https://bolt80.com/jouvence",
     keywords='fountain screenplay screenwriting screenwriter',
     packages=find_packages(),
     include_package_data=True,
@@ -37,6 +37,6 @@
     install_requires=install_requires,
     entry_points={
         'console_scripts': [
-            'fontaine = fontaine.cli.main'
+            'jouvence = jouvence.cli.main'
         ]}
 )
--- a/tests/conftest.py	Wed Jan 04 08:51:32 2017 -0800
+++ b/tests/conftest.py	Wed Jan 04 09:02:29 2017 -0800
@@ -3,32 +3,32 @@
 import logging
 import yaml
 import pytest
-from fontaine.document import (
-    FontaineSceneElement,
+from jouvence.document import (
+    JouvenceSceneElement,
     TYPE_ACTION, TYPE_CENTEREDACTION, TYPE_CHARACTER, TYPE_DIALOG,
     TYPE_PARENTHETICAL, TYPE_TRANSITION, TYPE_LYRICS, TYPE_PAGEBREAK,
     TYPE_EMPTYLINES,
     _scene_element_type_str)
-from fontaine.parser import FontaineParser, FontaineParserError
+from jouvence.parser import JouvenceParser, JouvenceParserError
 
 
 def pytest_addoption(parser):
     parser.addoption(
             '--log-debug',
             action='store_true',
-            help="Sets the Fontaine logger to output debug info to stdout.")
+            help="Sets the Jouvence logger to output debug info to stdout.")
 
 
 def pytest_configure(config):
     if config.getoption('--log-debug'):
         hdl = logging.StreamHandler(stream=sys.stdout)
-        logging.getLogger('fontaine').addHandler(hdl)
-        logging.getLogger('fontaine').setLevel(logging.DEBUG)
+        logging.getLogger('jouvence').addHandler(hdl)
+        logging.getLogger('jouvence').setLevel(logging.DEBUG)
 
 
 def pytest_collect_file(parent, path):
     if path.ext == '.yaml' and path.basename.startswith("test"):
-        return FontaineScriptTestFile(path, parent)
+        return JouvenceScriptTestFile(path, parent)
     return None
 
 
@@ -48,11 +48,11 @@
 
 def assert_paragraph(actual, expected):
     if isinstance(expected, str):
-        assert isinstance(actual, FontaineSceneElement)
+        assert isinstance(actual, JouvenceSceneElement)
         assert actual.type == TYPE_ACTION
         assert actual.text == expected
-    elif isinstance(expected, FontaineSceneElement):
-        assert isinstance(actual, FontaineSceneElement)
+    elif isinstance(expected, JouvenceSceneElement):
+        assert isinstance(actual, JouvenceSceneElement)
         assert actual.type == expected.type
         assert actual.text == expected.text
     else:
@@ -60,23 +60,23 @@
 
 
 def _c(name):
-    return FontaineSceneElement(TYPE_CHARACTER, name)
+    return JouvenceSceneElement(TYPE_CHARACTER, name)
 
 
 def _p(text):
-    return FontaineSceneElement(TYPE_PARENTHETICAL, text)
+    return JouvenceSceneElement(TYPE_PARENTHETICAL, text)
 
 
 def _d(text):
-    return FontaineSceneElement(TYPE_DIALOG, text)
+    return JouvenceSceneElement(TYPE_DIALOG, text)
 
 
 def _t(text):
-    return FontaineSceneElement(TYPE_TRANSITION, text)
+    return JouvenceSceneElement(TYPE_TRANSITION, text)
 
 
 def _l(text):
-    return FontaineSceneElement(TYPE_LYRICS, text)
+    return JouvenceSceneElement(TYPE_LYRICS, text)
 
 
 class UnexpectedScriptOutput(Exception):
@@ -85,23 +85,23 @@
         self.expected = expected
 
 
-class FontaineScriptTestFile(pytest.File):
+class JouvenceScriptTestFile(pytest.File):
     def collect(self):
         spec = yaml.load_all(self.fspath.open(encoding='utf8'))
         for i, item in enumerate(spec):
             name = '%s_%d' % (self.fspath.basename, i)
             if 'test_name' in item:
                 name += '_%s' % item['test_name']
-            yield FontaineScriptTestItem(name, self, item)
+            yield JouvenceScriptTestItem(name, self, item)
 
 
-class FontaineScriptTestItem(pytest.Item):
+class JouvenceScriptTestItem(pytest.Item):
     def __init__(self, name, parent, spec):
         super().__init__(name, parent)
         self.spec = spec
 
     def reportinfo(self):
-        return self.fspath, 0, "fontaine script test: %s" % self.name
+        return self.fspath, 0, "jouvence script test: %s" % self.name
 
     def runtest(self):
         intext = self.spec.get('in')
@@ -110,7 +110,7 @@
         if intext is None or expected is None:
             raise Exception("No 'in' or 'out' specified.")
 
-        parser = FontaineParser()
+        parser = JouvenceParser()
         doc = parser.parseString(intext)
         if title is not None:
             assert title == doc.title_values
@@ -122,7 +122,7 @@
             raise UnexpectedScriptOutput(doc.scenes, exp_scenes)
 
     def repr_failure(self, excinfo):
-        if isinstance(excinfo.value, FontaineParserError):
+        if isinstance(excinfo.value, JouvenceParserError):
             return ('\n'.join(
                 ['Parser error:', str(excinfo.value)]))
         if isinstance(excinfo.value, UnexpectedScriptOutput):
@@ -167,12 +167,12 @@
 
     for item in spec:
         if item == '<pagebreak>':
-            cur_paras.append(FontaineSceneElement(TYPE_PAGEBREAK, None))
+            cur_paras.append(JouvenceSceneElement(TYPE_PAGEBREAK, None))
             continue
 
         if RE_BLANK_LINE.match(item):
             text = len(item) * '\n'
-            cur_paras.append(FontaineSceneElement(TYPE_EMPTYLINES, text))
+            cur_paras.append(JouvenceSceneElement(TYPE_EMPTYLINES, text))
             continue
 
         token = item[:1]
@@ -184,7 +184,7 @@
         elif token == '!':
             if item[1:3] == '><':
                 cur_paras.append(
-                    FontaineSceneElement(TYPE_CENTEREDACTION, item[3:]))
+                    JouvenceSceneElement(TYPE_CENTEREDACTION, item[3:]))
             else:
                 cur_paras.append(item[1:])
         elif token == '@':
--- a/tests/test_renderer.py	Wed Jan 04 08:51:32 2017 -0800
+++ b/tests/test_renderer.py	Wed Jan 04 09:02:29 2017 -0800
@@ -1,5 +1,5 @@
 import pytest
-from fontaine.renderer import BaseTextRenderer
+from jouvence.renderer import BaseTextRenderer
 
 
 class TestTextRenderer(BaseTextRenderer):