changeset 1:74b83e3d921e

Add more states, add more tests.
author Ludovic Chabant <ludovic@chabant.com>
date Mon, 02 Jan 2017 21:54:59 -0800
parents 243401c49520
children 59fe8cb6190d
files fontaine/document.py fontaine/parser.py fontaine/renderer.py tests/conftest.py tests/test_character.yaml tests/test_dialogue.yaml tests/test_lyrics.yaml tests/test_pagebreak.yaml tests/test_parenthetical.yaml tests/test_renderer.py tests/test_scripts.yaml tests/test_titlepage.yaml
diffstat 12 files changed, 612 insertions(+), 90 deletions(-) [+]
line wrap: on
line diff
--- a/fontaine/document.py	Mon Jan 02 12:30:49 2017 -0800
+++ b/fontaine/document.py	Mon Jan 02 21:54:59 2017 -0800
@@ -46,8 +46,9 @@
                                    'TYPE_%s' % add_type_name.upper())
 
                 def _type_adder(_text):
-                    self.paragraphs.append(
-                        FontaineSceneElement(add_type, _text))
+                    new_p = FontaineSceneElement(add_type, _text)
+                    self.paragraphs.append(new_p)
+                    return new_p
 
                 adder = _type_adder
                 self._adders[add_type_name] = adder
@@ -55,6 +56,9 @@
         else:
             raise AttributeError
 
+    def addPageBreak(self):
+        self.paragraphs.append(FontaineSceneElement(TYPE_PAGEBREAK, None))
+
     def lastParagraph(self):
         try:
             return self.paragraphs[-1]
@@ -74,20 +78,32 @@
 
 
 TYPE_ACTION = 0
-TYPE_CHARACTER = 1
-TYPE_DIALOG = 2
-TYPE_PARENTHETICAL = 3
+TYPE_CENTEREDACTION = 1
+TYPE_CHARACTER = 2
+TYPE_DIALOG = 3
+TYPE_PARENTHETICAL = 4
+TYPE_TRANSITION = 5
+TYPE_LYRICS = 6
+TYPE_PAGEBREAK = 7
 
 
 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'
     raise NotImplementedError()
 
 
--- a/fontaine/parser.py	Mon Jan 02 12:30:49 2017 -0800
+++ b/fontaine/parser.py	Mon Jan 02 21:54:59 2017 -0800
@@ -7,6 +7,10 @@
 
 class FontaineState:
     can_merge = False
+    needs_pending_empty_lines = True
+
+    def __init__(self):
+        self.has_pending_empty_line = False
 
     def match(self, fp, ctx):
         return False
@@ -14,10 +18,18 @@
     def consume(self, fp, ctx):
         raise NotImplementedError()
 
+    def merge(self):
+        pass
+
     def exit(self, ctx):
         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))
@@ -39,42 +51,34 @@
         self._cur_key = None
         self._cur_val = None
 
-    def consume(self, fp, ctx):
-        line = fp.readline()
-        if not line:
-            return EOF_STATE
+    def match(self, fp, ctx):
+        line = fp.peekline()
+        return RE_TITLE_KEY_VALUE.match(line)
 
-        if RE_EMPTY_LINE.match(line):
-            self._commit(ctx)
-            # Finished with the page title, now move on to the first scene.
-            # However, if we never had any page title, go back to the beginning
-            # so we don't consume anybody else's empty lines.
-            if len(ctx.document.title_values) == 0:
-                fp.seek0()
-            return ANY_STATE
+    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')
-            self._cur_val = line[m.end():].strip()
-        else:
-            if self._cur_val is None:
-                if len(ctx.document.title_values) == 0:
-                    # Early exit because there's no title page.
-                    # Go back to the beginning so we don't consume somebody's
-                    # first line of text.
-                    fp.seek0()
-                    return ANY_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')
+                self._cur_val = line[m.end():].strip()
+            else:
+                # Keep accumulating the value of one of the title page's
+                # values.
+                self._cur_val += line.strip()
 
-                raise FontaineParserError(
-                    fp.line_no,
-                    "Page title needs to be followed by 2 empty lines.")
+            if RE_EMPTY_LINE.match(fp.peekline()):
+                self._commit(ctx)
+                # Finished with the page title, now move on to the first scene.
+                self.has_pending_empty_line = True
+                break
 
-            # Keep accumulating the value of one of the title page's values.
-            self._cur_val += line.strip()
-        return True
+        return ANY_STATE
 
     def exit(self, ctx):
         self._commit(ctx)
@@ -92,25 +96,30 @@
 
 class _SceneHeaderState(FontaineState):
     def match(self, fp, ctx):
-        lines = fp.peeklines(2)
+        lines = fp.peeklines(3)
         return (
             RE_EMPTY_LINE.match(lines[0]) and
-            RE_SCENE_HEADER_PATTERN.match(lines[1]))
+            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)
+        self.has_pending_empty_line = True
         return ANY_STATE
 
 
 class _ActionState(FontaineState):
     can_merge = True
+    needs_pending_empty_lines = False
 
     def __init__(self):
         super().__init__()
         self.text = ''
+        self._to_merge = None
+        self._was_merged = False
 
     def match(self, fp, ctx):
         return True
@@ -123,21 +132,85 @@
                 return EOF_STATE
 
             if is_first_line:
-                line = line.lstrip('!')
+                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()):
+                stripped_line = line.rstrip("\r\n")
+                self.text += stripped_line
+                self._to_merge = line[len(stripped_line):]
+                break
+            # ...otherwise, add the line with in full.
             self.text += line
 
-            if RE_EMPTY_LINE.match(fp.peekline()):
-                break
+        return ANY_STATE
 
-        return ANY_STATE
+    def merge(self):
+        # Put back the stuff we stripped from what we thought was the
+        # last line.
+        self.text += self._to_merge
+        self._was_merged = True
 
     def exit(self, ctx):
         ctx.document.lastScene().addAction(self.text)
 
 
-RE_CHARACTER_LINE = re.compile(r"^[A-Z\-]+\s*(\(.*\))?$", re.M)
+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.has_pending_empty_line = True
+                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
+                self.has_pending_empty_line = True
+                break
+            self.text += clean_line + eol
+
+        return ANY_STATE
+
+    def exit(self, ctx):
+        if not self._aborted:
+            ctx.document.lastScene().addCenteredAction(self.text)
+
+
+RE_CHARACTER_LINE = re.compile(r"^\s*[A-Z\-]+\s*(\(.*\))?$", re.M)
 
 
 class _CharacterState(FontaineState):
@@ -150,6 +223,7 @@
     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]
@@ -166,9 +240,15 @@
         return RE_PARENTHETICAL_LINE.match(line)
 
     def consume(self, fp, ctx):
-        line = fp.readline().rstrip('\r\n')
+        line = fp.readline().lstrip().rstrip('\r\n')
         ctx.document.lastScene().addParenthetical(line)
-        return [_DialogState, _CharacterState, _ActionState]
+
+        next_line = fp.peekline()
+        if not RE_EMPTY_LINE.match(next_line):
+            return _DialogState()
+
+        self.has_pending_empty_line = True
+        return ANY_STATE
 
 
 class _DialogState(FontaineState):
@@ -177,6 +257,8 @@
         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)
 
@@ -185,9 +267,23 @@
             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')
+                self.has_pending_empty_line = True
+                break
             self.text += line
-            if RE_EMPTY_LINE.match(fp.peekline()):
-                break
+
         return ANY_STATE
 
     def exit(self, ctx):
@@ -195,11 +291,80 @@
 
 
 class _LyricsState(FontaineState):
-    pass
+    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.has_pending_empty_line = True
+                self._aborted = True
+                return _ActionState()
+
+            if RE_EMPTY_LINE.match(fp.peekline()):
+                self.text += line.rstrip('\r\n')
+                self.has_pending_empty_line = True
+                break
+            self.text += line
+
+        return ANY_STATE
+
+    def exit(self, ctx):
+        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):
-    pass
+    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)
+        self.has_pending_empty_line = True
+
+
+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()
+        self.has_pending_empty_line = True
+        return ANY_STATE
 
 
 class _ForcedParagraphStates(FontaineState):
@@ -214,24 +379,41 @@
     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
-                lines[1][:1] in self.STATE_SYMBOLS):
-            self._state_cls = self.STATE_SYMBOLS[lines[1][:1]]
+                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()
 
 
-STATES = [
+ROOT_STATES = [
     _ForcedParagraphStates,  # Must be first.
     _SceneHeaderState,
     _CharacterState,
     _TransitionState,
+    _PageBreakState,
+    _CenteredActionState,
     _ActionState,  # Must be last.
 ]
 
@@ -240,25 +422,21 @@
     def __init__(self, fp):
         self.line_no = 1
         self._fp = fp
-
-    def read(self, size=-1):
-        return self._doRead(size, True)
-
-    def read1(self):
-        return self.read(1)
-
-    def peek1(self):
-        pos = self._fp.tell()
-        c = self._doRead(1, False)
-        self._fp.seek(pos)
-        return c
+        self._blankAt0 = False
 
     def readline(self, size=-1):
+        if self._blankAt0:
+            self._blankAt0 = False
+            return '\n'
+
         data = self._fp.readline(size)
         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)
@@ -267,16 +445,29 @@
     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 seek0(self):
-        self._fp.seek(0)
-        self.line_no = 1
+    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 _doRead(self, size, advance_line_no):
+    def _addBlankAt0(self):
+        if self._fp.tell() != 0:
+            raise Exception(
+                "Can't add blank line at 0 if reading has started.")
+        self._blankAt0 = True
+
+    def _read(self, size, advance_line_no):
         data = self._fp.read(size)
         if advance_line_no:
             self.line_no += data.count('\n')
@@ -294,7 +485,24 @@
         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()
+                # Make this added empty line "pending" so if the first line
+                # is an action paragraph, it doesn't include it.
+                self.state.has_pending_empty_line = True
+
+        # 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()))
@@ -313,17 +521,13 @@
                     "states, or `EOF_STATE` if they reached the end of the "
                     "file.")
 
-            if res is True:
-                # State continues to consume.
-                continue
-
-            if res is ANY_STATE or isinstance(res, list):
+            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 = STATES
+                    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:
@@ -337,31 +541,43 @@
                 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:
                     if type(self.state) == type(res) and self.state.can_merge:
                         # Don't switch states if the next state is the same
                         # type and that type supports merging.
+                        self.state.merge()
                         continue
 
                     self.state.exit(self)
+                    if (self.state.has_pending_empty_line and
+                            not res.needs_pending_empty_lines):
+                        logger.debug("Skipping pending blank line from %s" %
+                                     self.state.__class__.__name__)
+                        self.fp.readline()
 
                 self.state = res
-                continue
 
-            if isinstance(res, FontaineState):
+            elif isinstance(res, FontaineState):
                 # State wants to exit, wants a specific state to be next.
                 if self.state:
                     self.state.exit(self)
+                    if (self.state.has_pending_empty_line and
+                            not res.needs_pending_empty_lines):
+                        logger.debug("Skipping pending blank line from %s" %
+                                     self.state.__class__.__name__)
+                        self.fp.readline()
                 self.state = res
-                continue
 
-            if res is EOF_STATE:
+            elif res is EOF_STATE:
                 # Reached end of file.
                 if self.state:
                     self.state.exit(self)
                 break
 
-            raise Exception("Unsupported state result: %s" % res)
+            else:
+                raise Exception("Unsupported state result: %s" % res)
 
 
 class FontaineParser:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fontaine/renderer.py	Mon Jan 02 21:54:59 2017 -0800
@@ -0,0 +1,49 @@
+import re
+
+
+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 BaseRenderer:
+    def render_text(self, text):
+        # Replace bold stuff to catch double asterisks.
+        text = RE_BOLD.sub(self._do_write_bold, text)
+        text = RE_ITALICS.sub(self._do_write_italics, text)
+        text = RE_UNDERLINE.sub(self._do_write_underline, text)
+
+        return text
+
+    def _do_write_italics(self, m):
+        if m.group('esc'):
+            return m.group('before') + '*' + m.group('text') + '*'
+        return (
+            m.group('before') +
+            self.write_italics(m.group('text')))
+
+    def _do_write_bold(self, m):
+        if m.group('esc'):
+            return m.group('before') + '**' + m.group('text') + '**'
+        return (
+            m.group('before') +
+            self.write_bold(m.group('text')))
+
+    def _do_write_underline(self, m):
+        if m.group('esc'):
+            return m.group('before') + '_' + m.group('text') + '_'
+        return (
+            m.group('before') +
+            self.write_underline(m.group('text')))
+
+    def write_italics(self, text):
+        raise NotImplementedError()
+
+    def write_bold(self, text):
+        raise NotImplementedError()
+
+    def write_underline(self, text):
+        raise NotImplementedError()
--- a/tests/conftest.py	Mon Jan 02 12:30:49 2017 -0800
+++ b/tests/conftest.py	Mon Jan 02 21:54:59 2017 -0800
@@ -4,8 +4,11 @@
 import pytest
 from fontaine.document import (
     FontaineSceneElement,
-    TYPE_ACTION, TYPE_CHARACTER, TYPE_DIALOG, TYPE_PARENTHETICAL)
+    TYPE_ACTION, TYPE_CENTEREDACTION, TYPE_CHARACTER, TYPE_DIALOG,
+    TYPE_PARENTHETICAL, TYPE_TRANSITION, TYPE_LYRICS, TYPE_PAGEBREAK,
+    _scene_element_type_str)
 from fontaine.parser import FontaineParser, FontaineParserError
+from fontaine.renderer import BaseRenderer
 
 
 def pytest_addoption(parser):
@@ -67,6 +70,20 @@
     return FontaineSceneElement(TYPE_DIALOG, text)
 
 
+def _t(text):
+    return FontaineSceneElement(TYPE_TRANSITION, text)
+
+
+def _l(text):
+    return FontaineSceneElement(TYPE_LYRICS, text)
+
+
+class UnexpectedScriptOutput(Exception):
+    def __init__(self, actual, expected):
+        self.actual = actual
+        self.expected = expected
+
+
 class FontaineScriptTestFile(pytest.File):
     def collect(self):
         spec = yaml.load_all(self.fspath.open(encoding='utf8'))
@@ -96,15 +113,46 @@
         doc = parser.parseString(intext)
         if title is not None:
             assert title == doc.title_values
-        assert_scenes(doc.scenes, make_scenes(expected))
+
+        exp_scenes = make_scenes(expected)
+        try:
+            assert_scenes(doc.scenes, exp_scenes)
+        except AssertionError:
+            raise UnexpectedScriptOutput(doc.scenes, exp_scenes)
 
     def repr_failure(self, excinfo):
         if isinstance(excinfo.value, FontaineParserError):
             return ('\n'.join(
                 ['Parser error:', str(excinfo.value)]))
+        if isinstance(excinfo.value, UnexpectedScriptOutput):
+            return ('\n'.join(
+                ['Unexpected output:'] +
+                ['', 'Actual:'] +
+                list(_repr_doc_scenes(excinfo.value.actual)) +
+                ['', 'Expected:'] +
+                list(_repr_expected_scenes(excinfo.value.expected))))
         return super().repr_failure(excinfo)
 
 
+def _repr_doc_scenes(scenes):
+    for s in scenes:
+        yield 'Scene: "%s"' % s.header
+        for p in s.paragraphs:
+            yield '  %s: "%s"' % (_scene_element_type_str(p.type),
+                                  p.text)
+
+
+def _repr_expected_scenes(scenes):
+    for s in scenes:
+        yield 'Scene: "%s"' % s[0]
+        for p in s[1:]:
+            if isinstance(p, str):
+                yield '  ACTION: "%s"' % p
+            else:
+                yield '  %s: "%s"' % (_scene_element_type_str(p.type),
+                                      p.text)
+
+
 def make_scenes(spec):
     if not isinstance(spec, list):
         raise Exception("Script specs must be lists.")
@@ -114,21 +162,45 @@
     cur_paras = []
 
     for item in spec:
+        if item == '<pagebreak>':
+            cur_paras.append(FontaineSceneElement(TYPE_PAGEBREAK, None))
+            continue
+
         token = item[:1]
         if token == '.':
             if cur_header or cur_paras:
                 out.append([cur_header] + cur_paras)
             cur_header = item[1:]
+            cur_paras = []
         elif token == '!':
-            cur_paras.append(item[1:])
+            if item[1:3] == '><':
+                cur_paras.append(
+                    FontaineSceneElement(TYPE_CENTEREDACTION, item[3:]))
+            else:
+                cur_paras.append(item[1:])
         elif token == '@':
             cur_paras.append(_c(item[1:]))
         elif token == '=':
             cur_paras.append(_d(item[1:]))
         elif token == '_':
             cur_paras.append(_p(item[1:]))
+        elif token == '>':
+            cur_paras.append(_t(item[1:]))
+        elif token == '~':
+            cur_paras.append(_l(item[1:]))
         else:
             raise Exception("Unknown token: %s" % token)
     if cur_header or cur_paras:
         out.append([cur_header] + cur_paras)
     return out
+
+
+class TestRenderer(BaseRenderer):
+    def write_bold(self, text):
+        pass
+
+    def write_italics(self, text):
+        pass
+
+    def write_underline(self, text):
+        pass
--- a/tests/test_character.yaml	Mon Jan 02 12:30:49 2017 -0800
+++ b/tests/test_character.yaml	Mon Jan 02 21:54:59 2017 -0800
@@ -1,5 +1,5 @@
 ---
-in: "\nSTEEL\nThe man's a myth!"
+in: "STEEL\nThe man's a myth!"
 out:
     - '@STEEL'
     - "=The man's a myth!"
@@ -14,7 +14,7 @@
     - '@HANS (on the radio)'
     - "=What was it you said?"
 ---
-in: "\n@McCLANE\nYippie ki-yay!"
+in: "@McCLANE\nYippie ki-yay!"
 out:
     - '@McCLANE'
     - "=Yippie ki-yay!"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_dialogue.yaml	Mon Jan 02 21:54:59 2017 -0800
@@ -0,0 +1,16 @@
+---
+in: |
+    SANBORN
+    A good ole boy.
+out:
+    - "@SANBORN"
+    - "=A good ole boy."
+---
+in: |
+    DAN
+    Then let's retire them.
+    _Permanently_.
+out:
+    - "@DAN"
+    - "=Then let's retire them.\n_Permanently_."
+    
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_lyrics.yaml	Mon Jan 02 21:54:59 2017 -0800
@@ -0,0 +1,10 @@
+---
+in: "~Willy Wonka! Willy Wonka! The amazing chocolatier!"
+out:
+    - "~Willy Wonka! Willy Wonka! The amazing chocolatier!"
+---
+in: |
+    ~This is lyrics
+    But this is not
+out:
+    - "!~This is lyrics\nBut this is not"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_pagebreak.yaml	Mon Jan 02 21:54:59 2017 -0800
@@ -0,0 +1,16 @@
+---
+in: |
+    Here is some action.
+
+    NARRATOR
+    A page break is coming.
+
+    ===
+
+    There it was.
+out:
+    - "!Here is some action."
+    - "@NARRATOR"
+    - "=A page break is coming."
+    - "<pagebreak>"
+    - "!There it was."
--- a/tests/test_parenthetical.yaml	Mon Jan 02 12:30:49 2017 -0800
+++ b/tests/test_parenthetical.yaml	Mon Jan 02 21:54:59 2017 -0800
@@ -1,6 +1,5 @@
 ---
 in: |
-
     STEEL
     (starting the engine)
     So much for retirement!
@@ -8,3 +7,18 @@
     - '@STEEL'
     - '_(starting the engine)'
     - '=So much for retirement!'
+---
+in: |
+    STEEL
+    They're coming out of the woodwork!
+    (pause)
+    No, everybody we've put away!
+    (pause)
+    Point Blank Sniper?
+out:
+    - "@STEEL"
+    - "=They're coming out of the woodwork!"
+    - "_(pause)"
+    - "=No, everybody we've put away!"
+    - "_(pause)"
+    - "=Point Blank Sniper?"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_renderer.py	Mon Jan 02 21:54:59 2017 -0800
@@ -0,0 +1,64 @@
+import pytest
+from fontaine.renderer import BaseRenderer
+
+
+class TestRenderer(BaseRenderer):
+    def write_bold(self, text):
+        return 'B:' + text + ':B'
+
+    def write_italics(self, text):
+        return 'I:' + text + ':I'
+
+    def write_underline(self, text):
+        return 'U:' + text + ':U'
+
+
+@pytest.mark.parametrize('intext, expected', [
+    ("_Underline_", "U:Underline:U"),
+    ("Here's an _underline_.", "Here's an U:underline:U."),
+    ("Here's an _underline_ too", "Here's an U:underline:U too"),
+    ("This is not_underline_", "This is not_underline_"),
+    ("This is not _underline_either", "This is not _underline_either"),
+    ("This is _two underlined_ words.", "This is U:two underlined:U words."),
+    ("This is _three underlined words_.",
+     "This is U:three underlined words:U."),
+    ("This is an \_escaped_ one.", "This is an _escaped_ one.")
+])
+def test_underline(intext, expected):
+    r = TestRenderer()
+    out = r.render_text(intext)
+    assert out == expected
+
+
+@pytest.mark.parametrize('intext, expected', [
+    ("*Italics*", "I:Italics:I"),
+    ("Here's some *italics*.", "Here's some I:italics:I."),
+    ("Here's some *italics* too", "Here's some I:italics:I too"),
+    ("This is not*italics*", "This is not*italics*"),
+    ("This is not *italics*either", "This is not *italics*either"),
+    ("This is *two italics* words.", "This is I:two italics:I words."),
+    ("This is *three italics words*.",
+     "This is I:three italics words:I."),
+    ("This is some \*escaped* one.", "This is some *escaped* one.")
+])
+def test_italics(intext, expected):
+    r = TestRenderer()
+    out = r.render_text(intext)
+    assert out == expected
+
+
+@pytest.mark.parametrize('intext, expected', [
+    ("**Bold**", "B:Bold:B"),
+    ("Here's some **bold**.", "Here's some B:bold:B."),
+    ("Here's some **bold** too", "Here's some B:bold:B too"),
+    ("This is not**bold**", "This is not**bold**"),
+    ("This is not **bold**either", "This is not **bold**either"),
+    ("This is **two bold** words.", "This is B:two bold:B words."),
+    ("This is **three bold words**.",
+     "This is B:three bold words:B."),
+    ("This is some \**escaped** one.", "This is some **escaped** one.")
+])
+def test_bold(intext, expected):
+    r = TestRenderer()
+    out = r.render_text(intext)
+    assert out == expected
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test_scripts.yaml	Mon Jan 02 21:54:59 2017 -0800
@@ -0,0 +1,49 @@
+---
+in: |
+    The General Lee flies through the air. FREEZE FRAME.
+
+    NARRATOR
+    Shoot, to the Dukes that's about like taking Grandma for a Sunday drive.
+
+    >**End of Act One**<
+
+    ===
+
+    >**Act Two**<
+
+    The General Lee hangs in the air, right where we left it.
+out:
+    - "!The General Lee flies through the air. FREEZE FRAME."
+    - "@NARRATOR"
+    - "=Shoot, to the Dukes that's about like taking Grandma for a Sunday drive."
+    - "!><**End of Act One**"
+    - "<pagebreak>"
+    - "!><**Act Two**"
+    - "!The General Lee hangs in the air, right where we left it."
+---
+in: |
+    EXT. BRICK'S POOL - DAY
+
+    Steel, in the middle of a heated phone call:
+
+    STEEL
+    They're coming out of the woodwork!
+    (pause)
+    No, everybody we've put away!
+    (pause)
+    Point Blank Sniper?
+
+    .SNIPER SCOPE POV
+
+    From what seems like only INCHES AWAY.  _Steel's face FILLS the *Leupold Mark 4* scope_.
+out:
+    - ".EXT. BRICK'S POOL - DAY"
+    - "!Steel, in the middle of a heated phone call:"
+    - "@STEEL"
+    - "=They're coming out of the woodwork!"
+    - "_(pause)"
+    - "=No, everybody we've put away!"
+    - "_(pause)"
+    - "=Point Blank Sniper?"
+    - ".SNIPER SCOPE POV"
+    - "!From what seems like only INCHES AWAY.  _Steel's face FILLS the *Leupold Mark 4* scope_."
--- a/tests/test_titlepage.yaml	Mon Jan 02 12:30:49 2017 -0800
+++ b/tests/test_titlepage.yaml	Mon Jan 02 21:54:59 2017 -0800
@@ -6,17 +6,17 @@
 in: "\n"
 title: {}
 out:
-    - "!\n"
+    - "!"
 ---
 in: "\n\n"
 title: {}
 out:
-    - "!\n\n"
+    - "!\n"
 ---
 in: "\n\n\n"
 title: {}
 out:
-    - "!\n\n\n"
+    - "!\n\n"
 ---
 in: |
     Title: This simple test
@@ -33,5 +33,5 @@
 title:
     Title: "This simple test"
 out:
-    - "!It doesn't have much.\n"
+    - "!It doesn't have much."