# ABOUTME: Fountain markup parser for converting Fountain screenwriting format to structured elements.
# Implements a two-pass parsing strategy with regex-based element classification.
"""
Fountain markup parser for converting Fountain screenwriting format to structured elements.
The parser architecture follows a structured two-pass approach designed to handle
Fountain's unique title page format followed by screenplay body content:
**Pass 1: Title Page Metadata Extraction**
- Parses key-value pairs from document header (Title:, Author:, etc.)
- Handles multi-line values and continuation lines
- Stops when encountering the first body element (scene heading, action, etc.)
- Supports all standard Fountain title page fields
**Pass 2: Body Element Classification**
- Line-by-line parsing using regex pattern matching
- Precedence-based classification (forced elements > natural patterns > context-dependent)
- Context-aware dialogue detection using lookahead and element history
- Dual dialogue post-processing to pair simultaneous character speech
**Regex Classification Hierarchy:**
1. Forced elements (!, @, >, .) - highest precedence override
2. Special markers (boneyard, page breaks, notes)
3. Natural structural patterns (scene headings, transitions)
4. Character name detection with dialogue lookahead
5. Context-dependent elements (dialogue, parentheticals)
6. Default fallback to action text
**Key Design Principles:**
- Lenient parsing: unmatched lines fall back to action; problems become diagnostics
- Regex patterns are class constants, each documented next to its definition
"""
from __future__ import annotations
import re
import string
from dataclasses import dataclass
from itertools import takewhile
from pathlib import Path
from typing import TYPE_CHECKING
from fountain.document import FountainDocument
from fountain.elements import ElementType, FormatSpan, FormatType, FountainElement, MetadataValue, ValidationIssue
if TYPE_CHECKING:
from os import PathLike
# Diagnostic codes emitted by FountainParser.validate(). Each string is a stable
# machine-readable contract; keep them here as the single source of truth so the
# growing diagnostic set stays consistent across the parser and its consumers.
CODE_UNCLOSED_BONEYARD = "unclosed-boneyard"
CODE_UNCLOSED_NOTE = "unclosed-note"
CODE_ORPHAN_CHARACTER_CUE = "orphan-character-cue"
CODE_EMPTY_DOCUMENT = "empty-document"
@dataclass
class _DelimiterRun:
"""A maximal run of one emphasis delimiter character (``*`` or ``_``).
Tracks how many of the run's delimiter characters have been consumed from each
end so a run that acts as both a closer (consuming from the left, adjacent to the
preceding content) and an opener (consuming from the right, adjacent to the
following content) reports correct span offsets.
"""
position: int
char: str
length: int
can_open: bool
can_close: bool
left_used: int = 0
right_used: int = 0
@property
def available(self) -> int:
"""Number of delimiter characters not yet consumed from either end."""
return self.length - self.left_used - self.right_used
[docs]
class FountainParser:
"""Parser for Fountain markup format.
This parser implements a two-pass parsing strategy for Fountain screenplay format:
1. First pass: Extract title page metadata from document header
2. Second pass: Parse body elements using regex pattern matching
The parser handles all Fountain specification elements including scene headings,
character names, dialogue, action lines, transitions, and special formatting.
Attributes:
lines: List of text lines being parsed
current_line: Current line index during parsing
elements: List of parsed FountainElement objects
in_boneyard: Flag tracking if parser is inside a multi-line comment block
Examples:
>>> parser = FountainParser()
>>> doc = parser.parse("INT. COFFEE SHOP - DAY\\n\\nJOHN\\nHello world.")
>>> len(doc.elements)
3
>>> doc.elements[0].type
<ElementType.SCENE_HEADING: 'scene_heading'>
"""
# Scene Heading Patterns
# Matches the scene heading prefixes the Fountain spec recognizes: INT, EXT, EST, I/E,
# INT/EXT, INT./EXT. The spelled-out INTERIOR/EXTERIOR are deliberately excluded (they
# are not spec prefixes, and accepting them turns ordinary prose like "Interior design
# is a career." into a false scene heading under case-insensitive matching).
# Each prefix is followed by a separator: a period (dot form, optional space before it) or a
# space (space form). Requiring a separator provides a prefix boundary so a word that merely
# starts with the prefix letters, like "INTERNAL", stays ACTION rather than matching on "INT".
# Case-insensitive matching allows for "int." or "Int." variations.
# Examples: "INT. COFFEE SHOP - DAY", "EXT PARK - NIGHT", "I/E. CAR - CONTINUOUS"
SCENE_HEADING_PATTERN = re.compile(
r"^(?:INT|EXT|EST|I/E|INT/EXT|INT\./EXT)(?:\s*\.|\s)",
re.IGNORECASE,
)
# Matches scene numbers in format #SCENE_NUMBER# at end of scene headings
# Captures the scene number content between the hash marks. The content is
# restricted to alphanumerics, dashes, and periods (B4); a group holding any
# other character is not a scene number and is left verbatim in the heading.
# Example: "INT. HOUSE - DAY #1A#" captures "1A"
SCENE_NUMBER_PATTERN = re.compile(r"\s*#([A-Za-z0-9.-]+)#\s*$")
# Forced scene heading starts with period (.) to override natural scene detection
# Used when a line should be a scene heading but doesn't match standard prefixes
# Example: ".FLASHBACK - 10 YEARS AGO" becomes "FLASHBACK - 10 YEARS AGO"
FORCED_SCENE_HEADING_PATTERN = re.compile(r"^\.(?!\.)(?=[A-Za-z0-9])")
# Shared character-name fragments (C1/C2), reused by the three cue patterns below so a
# change to the allowed characters is made in one place. ``_CHAR_CLASS`` is the set a
# cue may contain; ``_CHAR_NAME`` requires at least one uppercase letter (the lookahead)
# while allowing a digit-first cue.
_CHAR_CLASS = r"[A-Z0-9\s_.'#-]"
_CHAR_NAME = rf"(?={_CHAR_CLASS}*[A-Z])[A-Z0-9]{_CHAR_CLASS}*"
# Character Name Patterns
# Standard character names: ALL CAPS, may include numbers, spaces, underscores, and
# cue punctuation (C1): a period, apostrophe, or hyphen, plus ``#`` for numbered
# extras. The first character may be a letter or a digit so a digit-first cue like
# "23 SKIDOO" is recognized (C2), but the leading ``(?=...)`` lookahead requires at
# least one uppercase letter somewhere in the cue so a purely numeric line such as
# "23", "007", or "42" stays ACTION. The class deliberately excludes lowercase
# letters so a cue stays recognizably uppercase; recognition is further gated by a
# blank line before and a dialogue lookahead after, so an all-caps action sentence
# still falls through to action.
# Examples: "JOHN", "MARY JANE", "ROBOT_1", "MR. SMITH", "O'BRIEN", "JEAN-CLAUDE",
# "DEALER #2", "23 SKIDOO"
CHARACTER_PATTERN = re.compile(rf"^{_CHAR_NAME}$")
# Dual dialogue character: standard character name followed by caret (^)
# Indicates this character speaks simultaneously with previous character
# Shares the C1/C2 name rules: digit-first allowed, at least one letter required.
# Example: "MARY^" for dual dialogue with preceding character
DUAL_CHARACTER_PATTERN = re.compile(rf"^{_CHAR_NAME}\^+\s*$")
# Forced character name: prefixed with @ to override natural character detection
# Captures the character name after the @ symbol
# Example: "@john" forces "john" to be treated as character (even lowercase)
FORCED_CHARACTER_PATTERN = re.compile(r"^@(.+)$")
# Trailing extension suffix on a forced cue (C7). Lifts a "(extension)" off the end
# of a forced ``@`` name so it lands in metadata like a natural cue. The forced path
# needs its own regex because a forced name may be any case (``@mcclane``), so the
# uppercase-gated CHARACTER_EXTENSION_PATTERN cannot match it. The name group excludes
# '(' so it cannot backtrack into the extension parens; this keeps matching linear on a
# crafted line with many unclosed parens instead of quadratic.
# Example: "McClane (O.S.)" captures name "McClane" and extension "O.S.".
FORCED_EXTENSION_PATTERN = re.compile(r"^([^(]*)\s*\((.+)\)$")
# Character with extensions: CHARACTER_NAME (extension) with optional dual dialogue caret
# Captures character name, extension (V.O., O.S., CONT'D, etc.), and dual dialogue marker
# The name portion shares the C1/C2 rules: digit-first allowed, at least one letter
# required (the lookahead scans the name, stopping at the extension's open paren).
# Examples: "JOHN (V.O.)", "MARY (O.S.)^", "NARRATOR (CONT'D)", "MR. SMITH (V.O.)"
CHARACTER_EXTENSION_PATTERN = re.compile(
rf"^(?={_CHAR_CLASS}*[A-Z])([A-Z0-9]{_CHAR_CLASS}*)\s*\((.+)\)\s*(\^+)?\s*$"
)
# Transition Patterns
# Standard transitions: ALL CAPS ending with colon, or specific fade patterns
# Matches common screenplay transitions like "CUT TO:", "FADE IN:", "FADE OUT."
# The ``TO:`` alternative allows internal punctuation (hyphen, period, apostrophe,
# slash) so hyphenated transitions like "SMASH-CUT TO:" and "MATCH-CUT TO:" are
# recognized (D2). The class deliberately excludes lowercase letters so a line stays
# uppercase-oriented, and the literal ``TO:`` suffix keeps it end-anchored, so a
# mixed-case line like "Smash-Cut TO:" or an arbitrary punctuated line falls through
# to action. Pattern stays restrictive to avoid false positives.
TRANSITION_PATTERN = re.compile(r"^[A-Z\s.'/-]+TO:$|^FADE IN:$|^FADE OUT\.$|^CUT TO:$")
# Forced transition: prefixed with > to override natural transition detection
# Example: ">SPECIAL TRANSITION" forces transition treatment
FORCED_TRANSITION_PATTERN = re.compile(r"^>")
# Special Element Patterns
# Forced action: prefixed with ! to ensure line is treated as action
# Captures the action text after the ! symbol
# Example: "!This is definitely action" becomes "This is definitely action"
FORCED_ACTION_PATTERN = re.compile(r"^!(.+)$")
# Centered text: enclosed in >text< for center alignment
# Must start with > and end with < with no other < characters inside
# Example: ">THE END<" creates centered text
CENTERED_PATTERN = re.compile(r"^>[^<]*<$")
# Notes: inline comments in format [[note text]]
# Can appear anywhere in text, captured for special handling
# Example: "John walks [[this needs work]] to the door"
# Content allows any character except a bare "]]": a single "]" not followed by
# another "]" stays part of the note text, so only "]]" closes a note (E10). The
# content can never span a "]]", which keeps "[[a]] middle [[b]]" from matching as
# one note and preserves the E13 fullmatch guard and per-note inline stripping.
NOTE_PATTERN = re.compile(r"\[\[(?:[^\]]|\](?!\]))*\]\]")
# The same note body but also matching the spaces/tabs on either side, so removing a
# mid-line note collapses to a single space (word note word -> word word) instead of
# leaving a double-space seam. A note flush against a word or the line edge is removed
# with no inserted space (see :meth:`_collapse_note_seam`).
NOTE_SEAM_PATTERN = re.compile(r"[ \t]*\[\[(?:[^\]]|\](?!\]))*\]\][ \t]*")
# Boneyard (Comment) Patterns
# Single-line boneyard: /* comment */ on one line (DOTALL allows newlines in content)
# Used for comments that should not appear in final output
BONEYARD_PATTERN = re.compile(r"^/\*.*?\*/$", re.DOTALL)
# Document Structure Patterns
# Section headings: one or more # symbols followed by optional whitespace
# Used for document organization, similar to Markdown headers
# Examples: "# Act I", "## Scene 1", "### Subplot"
SECTION_PATTERN = re.compile(r"^#+\s*")
# Synopsis: prefixed with = for scene/section summaries
# Removes the = prefix to get synopsis content
# Example: "= John meets Mary for the first time"
# A synopsis is a single leading '=' followed by content. The (?!=) guard keeps a run
# of equals signs (e.g. "==") out, so it is not read as a synopsis of "="; two equals
# are neither a synopsis nor a page break (which needs 3+) and fall through to action.
SYNOPSIS_PATTERN = re.compile(r"^=(?!=)\s*")
# Page breaks: three or more equals signs on a line
# Forces a page break in formatted output
# Example: "===" or "================="
PAGE_BREAK_PATTERN = re.compile(r"^===+$")
# Lyrics: prefixed with ~ for song lyrics or musical elements
# Captures the lyric text after the ~ symbol
# Example: "~Happy birthday to you" for lyrics
LYRICS_PATTERN = re.compile(r"^~(.+)$")
# Structural patterns used by _is_dialogue_following to detect non-dialogue elements.
# Hard-structural patterns always disqualify a preceding cue: if the next line
# matches one of these, it is unambiguously not dialogue.
HARD_STRUCTURAL_PATTERNS: tuple[re.Pattern[str], ...] = (
SCENE_HEADING_PATTERN,
FORCED_SCENE_HEADING_PATTERN,
TRANSITION_PATTERN,
FORCED_TRANSITION_PATTERN,
SECTION_PATTERN,
SYNOPSIS_PATTERN,
PAGE_BREAK_PATTERN,
CENTERED_PATTERN,
FORCED_ACTION_PATTERN,
)
# Cue patterns are ambiguous: an all-caps line matching one of these is only a
# rival structural element (disqualifying a preceding cue) if it is ITSELF a real
# cue, that is, its own next non-empty line is dialogue. Otherwise it is dialogue
# for the preceding cue. This keeps punctuated shouts like ``NO. NEVER.`` and
# all-caps dialogue like ``I SAID NO`` from demoting the cue above them to action.
CUE_PATTERNS: tuple[re.Pattern[str], ...] = (
CHARACTER_PATTERN,
DUAL_CHARACTER_PATTERN,
FORCED_CHARACTER_PATTERN,
CHARACTER_EXTENSION_PATTERN,
)
# Inline emphasis is matched by a delimiter-run scanner (``_find_emphasis_spans``)
# rather than regexes: asterisk and underscore emphasis nests and shares runs
# (``*a **b** c*``, ``**bold***italic*``), which a regex content class cannot span.
# The scanner applies CommonMark-style flanking rules so a delimiter adjacent to a
# space does not emphasize and an intraword underscore stays literal.
# Delimiter width (characters on each side) per format type. Used by D4 to strip
# the delimiters from stored text and re-index spans onto the emphasized content.
DELIMITER_WIDTHS = {"bold_italic": 3, "bold": 2, "italic": 1, "underline": 1}
# Element types whose text carries inline emphasis. For these, D4 strips the
# emphasis delimiters from the stored text and keeps content-only spans. Verbatim
# types (BONEYARD, NOTE, PAGE_BREAK) and structural cues keep their text as-is,
# getting only backslash-escape resolution.
EMPHASIS_TYPES = frozenset(
{
ElementType.ACTION,
ElementType.SECTION,
ElementType.SYNOPSIS,
ElementType.LYRICS,
ElementType.SCENE_HEADING,
ElementType.CENTERED,
ElementType.TRANSITION,
ElementType.DIALOGUE,
ElementType.PARENTHETICAL,
}
)
[docs]
def __init__(self) -> None:
self.lines: list[str] = []
self.current_line = 0
self.elements: list[FountainElement] = []
self.in_boneyard = False
self.boneyard_start_line = 0
self.in_note = False
self.note_buffer: list[str] = []
self.note_start_line = 0
# Diagnostics collected during parsing (unclosed constructs, orphan cues, empty
# document). Reset each parse() and attached to the returned document.
self.diagnostics: list[ValidationIssue] = []
# Source line index of the last line added to the current action paragraph; only a
# directly-adjacent next line merges, so a boneyard gap does not join across it.
self._action_last_line = -1
# Text before a `/*` that opened a still-open multi-line boneyard. A boneyard
# removes the intervening newlines too, so this rejoins the post-close remainder on
# one line rather than becoming a separate element with a spurious line break.
self._boneyard_pretext = ""
self._boneyard_pretext_blank = False
[docs]
def parse(self, text: str) -> FountainDocument:
"""Parse Fountain text and return a FountainDocument.
Implements a two-pass parsing strategy:
1. First pass: Extract title page metadata from document header
2. Second pass: Parse body elements line by line using regex classification
The parser processes elements in order, maintaining context for dialogue
detection and handling special cases like dual dialogue pairing.
Args:
text: Raw Fountain markup text to parse
Returns:
FountainDocument containing parsed elements and metadata
Basic parsing with title page and dialogue:
>>> parser = FountainParser()
>>> script = "Title: My Script\\n\\nINT. HOUSE - DAY\\n\\nJOHN\\nHello there."
>>> doc = parser.parse(script)
>>> doc.metadata['title']
'My Script'
>>> len(doc.elements)
3
>>> doc.elements[0].type.value
'scene_heading'
Complex parsing with forced elements and formatting:
>>> complex_script = '''Title: Complex Script
... Author: Test Author
...
... .FLASHBACK - TITLE SEQUENCE
...
... @narrator
... This is **bold** and *italic* text.
...
... >THE END<'''
>>> doc = parser.parse(complex_script)
>>> doc.metadata['author']
'Test Author'
>>> doc.elements[0].metadata['forced']
True
>>> doc.elements[1].metadata['forced']
True
>>> len(doc.elements[2].formatting)
2
>>> doc.elements[3].type.value
'centered'
Complex dual dialogue with extensions and formatting:
>>> dual_script = '''Title: Dual Dialogue Test
...
... INT. RESTAURANT - NIGHT
...
... JOHN (V.O.)
... This is my **inner voice**.
...
... MARY (PHONE)^
... I can hear you from _here_.
...
... >THE END<'''
>>> doc = parser.parse(dual_script)
>>> doc.elements[0].type.value # Scene heading
'scene_heading'
>>> doc.elements[1].type.value # Dual dialogue element (characters combined)
'dual_dialogue'
>>> doc.elements[1].metadata['left_character'].metadata['extension']
'V.O.'
>>> doc.elements[1].metadata['right_character'].metadata['extension']
'PHONE'
>>> len(doc.elements[1].metadata['left_dialogue']) # Bold formatting preserved
1
>>> doc.elements[2].type.value # Centered text
'centered'
Boneyard comments and special elements:
>>> boneyard_script = '''/* This is a comment */
... INT. HOUSE - DAY
...
... [[This is a note]]
...
... JOHN
... Hello /* inline comment */ world.
...
... ===
...
... # Act Two
...
... = Synopsis of next scene'''
>>> doc = parser.parse(boneyard_script)
>>> doc.elements[0].type.value
'boneyard'
>>> doc.elements[2].type.value
'note'
>>> doc.elements[5].type.value
'page_break'
>>> doc.elements[6].type.value
'section'
>>> doc.elements[7].type.value
'synopsis'
"""
self.lines = text.split("\n")
self.current_line = 0
self.elements = []
self.in_boneyard = False
self.boneyard_start_line = 0
self.in_note = False
self.note_buffer = []
self.note_start_line = 0
self.diagnostics = []
self._action_last_line = -1
self._boneyard_pretext = ""
self._boneyard_pretext_blank = False
# First pass: extract title page
metadata = self._parse_title_page()
# Second pass: parse body elements
previous_line_was_blank = False
while self.current_line < len(self.lines):
raw_line = self.lines[self.current_line]
line = raw_line.rstrip()
if not line: # Empty after rstrip
# Check for whitespace-only line in dialogue context (spec: two spaces continues dialogue)
is_whitespace_only = bool(raw_line) and raw_line != raw_line.lstrip()
if self.in_note:
if is_whitespace_only:
# E6: a two-space connector line keeps an open note open and
# preserves an empty interior line, so the note text carries a
# blank line (a "\n\n") between its surrounding lines.
self.note_buffer.append("")
self.current_line += 1
continue
# E7: a genuinely blank line breaks an open note. The buffered
# bracket lines never closed, so they fall back to action text
# rather than surviving as one NOTE.
self._flush_open_note_as_text()
previous_line_was_blank = True
self.current_line += 1
continue
in_dialogue_context = self.elements and self.elements[-1].type in (
ElementType.DIALOGUE,
ElementType.PARENTHETICAL,
ElementType.CHARACTER,
)
if is_whitespace_only and in_dialogue_context:
# Whitespace-only line preserves a blank line within dialogue
self.elements.append(
FountainElement(
type=ElementType.DIALOGUE,
text="",
formatting=[],
line_number=self.current_line + 1,
)
)
self.current_line += 1
continue
previous_line_was_blank = True
self.current_line += 1
continue
element = self._parse_line(line, previous_line_was_blank, raw_line)
if element:
if (
element.type == ElementType.ACTION
and not previous_line_was_blank
and self.current_line == self._action_last_line + 1
and self.elements
and self.elements[-1].type == ElementType.ACTION
):
# Continuation of the current action paragraph: keep the line break
# (Fountain treats every carriage return as intentional). Emphasis does
# not cross line breaks, so extract THIS line independently and append it
# with an offset, keeping paragraph assembly O(n) overall rather than
# re-extracting the whole growing paragraph each line. Only a
# directly-adjacent line merges, so a boneyard gap does not join.
paragraph = self.elements[-1]
line_text, line_spans = self._extract_inline(element.text)
offset = len(paragraph.text) + 1 # +1 for the joining newline
paragraph.text = f"{paragraph.text}\n{line_text}"
paragraph.formatting = paragraph.formatting + [
FormatSpan(span.start + offset, span.end + offset, span.format_type) for span in line_spans
]
self._action_last_line = self.current_line
else:
if element.type == ElementType.ACTION:
self._action_last_line = self.current_line
self._finalize_inline(element)
self.elements.append(element)
# A note or boneyard line is invisible in output, so it is transparent for
# blank-line adjacency: leave the blank-before state unchanged across it. Every
# other line resets it.
transparent = (element is not None and element.type in (ElementType.NOTE, ElementType.BONEYARD)) or (
element is None and (self.in_note or self.in_boneyard)
)
if not transparent:
previous_line_was_blank = False
self.current_line += 1
# Record end-of-document diagnostics from the leftover parser state before the
# recovery below clears it, so parse() surfaces them on the returned document
# (and validate() reads them from there).
if self.in_boneyard:
self.diagnostics.append(
ValidationIssue(
line_number=self.boneyard_start_line,
severity="error",
code=CODE_UNCLOSED_BONEYARD,
message="Boneyard comment opened with '/*' but never closed",
)
)
# A note that opened with '[[' but reached end of input never closed. Recover its
# buffered lines as action rather than dropping them; the flush also records the
# unclosed-note diagnostic (shared with the blank-line-break path).
if self.in_note:
self._flush_open_note_as_text()
# An unclosed boneyard buffered its pre-text on the opening line; emit it so the
# body text before '/*' is not lost when no '*/' ever arrives. Clear in_boneyard
# first, or the reprocessed pre-text falls back into the still-open boneyard branch
# and is dropped.
if self.in_boneyard and self._boneyard_pretext:
self.in_boneyard = False
pretext_element = self._parse_line(self._boneyard_pretext, self._boneyard_pretext_blank)
self._boneyard_pretext = ""
if pretext_element:
self._finalize_inline(pretext_element)
self.elements.append(pretext_element)
# Post-process for dual dialogue pairing
self._process_dual_dialogue()
# An empty parse that nothing else already explains is itself worth flagging. A
# title-page-only document (metadata but no body elements) is valid, not empty.
if not self.elements and not self.diagnostics and not metadata:
self.diagnostics.append(
ValidationIssue(
line_number=1,
severity="warning",
code=CODE_EMPTY_DOCUMENT,
message="Document parsed to zero elements",
)
)
return FountainDocument(self.elements, metadata, issues=list(self.diagnostics))
[docs]
def parse_file(self, filepath: str | PathLike[str]) -> FountainDocument:
"""Parse a Fountain file and return a FountainDocument.
Convenience method that reads a Fountain file from disk and parses it into
a structured FountainDocument, decoding it as UTF-8.
Args:
filepath: Path to the Fountain file to parse, as a string or path object.
Can be absolute or relative.
Returns:
FountainDocument containing the parsed screenplay elements and metadata
Raises:
FileNotFoundError: If the specified file does not exist
OSError: If the file cannot be read
UnicodeDecodeError: If the file is not valid UTF-8
Examples:
>>> parser = FountainParser()
>>> doc = parser.parse_file("screenplay.fountain") # doctest: +SKIP
>>> print(f"Title: {doc.metadata.get('title', 'Untitled')}") # doctest: +SKIP
Title: My Great Screenplay
>>> len(doc.elements) # doctest: +SKIP
42
Note:
This method assumes UTF-8 encoding, which is standard for Fountain files.
If you need to handle other encodings, read the file manually and use
the parse() method instead.
"""
return self.parse(Path(filepath).read_text(encoding="utf-8"))
[docs]
def validate(self, text: str) -> list[ValidationIssue]:
"""Validate Fountain text and return a list of diagnostics.
Runs the same two-pass analysis as :meth:`parse`, but collects diagnostics
about structural problems instead of discarding the information. :meth:`parse`
stays lenient and non-raising; :meth:`validate` reports what a lenient parse
silently tolerated. Calling validate() does not change what a later parse()
of the same text returns, because parse() fully resets parser state on entry.
The initial diagnostic set covers:
- ``unclosed-boneyard`` (error): a ``/*`` comment opened but never closed
- ``unclosed-note`` (error): a ``[[`` note opened but never closed
- ``orphan-character-cue`` (warning): an uppercase cue demoted to action
because no dialogue follows it
- ``empty-document`` (warning): input that parses to zero elements
Args:
text: Raw Fountain markup text to validate
Returns:
List of :class:`~fountain.elements.ValidationIssue` diagnostics. Empty
when the document is well formed.
Examples:
>>> parser = FountainParser()
>>> parser.validate("INT. HOUSE - DAY\\n\\nJOHN\\nHello.")
[]
>>> issues = parser.validate("INT. HOUSE - DAY\\n\\n/* open")
>>> issues[0].code
'unclosed-boneyard'
>>> issues[0].severity
'error'
"""
# parse() records every diagnostic it can detect on the returned document, so
# validation is just a parse whose issues are surfaced as the return value.
return list(self.parse(text).issues)
# Recognized title-page fields (lowercase). A first line naming one of these opens a
# title page even when it is not a capitalized label (e.g. "draft date"). Arbitrary
# capitalized labels are also accepted so custom keys keep working.
TITLE_PAGE_KEYS: frozenset[str] = frozenset(
{
"title",
"credit",
"author",
"authors",
"source",
"notes",
"draft date",
"date",
"contact",
"copyright",
"revision",
"revised",
"version",
"format",
"created",
"writers",
"producer",
"director",
}
)
def _parse_title_page(self) -> dict[str, str]:
"""Parse title page metadata from the beginning of the document.
Extracts key-value pairs from the document header using Fountain's title page format.
Supports multi-line values and handles common title page fields like title, author,
credit, source, draft date, contact information, and custom fields.
The title page ends when a scene heading or other body element is encountered.
Multi-line values are supported by continuing on subsequent lines without a colon.
Returns:
Dict mapping field names (lowercase) to their string values
Examples:
>>> parser = FountainParser()
>>> parser.lines = ["Title: My Great Script", "Author: John Doe", "", "INT. HOUSE - DAY"]
>>> parser.current_line = 0
>>> metadata = parser._parse_title_page()
>>> metadata['title']
'My Great Script'
>>> metadata['author']
'John Doe'
Note:
Supported fields include: title, author, credit, source, draft date, contact,
authors, notes, copyright, date, revised, version, format, created, writers,
producer, director. Per the Fountain spec, any key ending with a colon is valid.
"""
metadata = {}
current_key = None
while self.current_line < len(self.lines):
raw_line = self.lines[self.current_line]
line = raw_line.strip()
if not line:
# Empty line ends title page when we have at least one key
if current_key is not None:
break
# Skip leading blank lines before title page content
self.current_line += 1
continue
# Indentation is tested on the raw (unstripped) line so we can see it:
# a leading tab or 3+ leading spaces marks a continuation (A2). The
# stripped ``line`` used above cannot carry this information.
leading_spaces = len(raw_line) - len(raw_line.lstrip(" "))
is_indented = raw_line.startswith("\t") or leading_spaces >= 3
# An indented line is always a value/continuation of the current key,
# even when it contains a colon (A2): an indented ``Draft 3: final``
# stays the current key's value rather than opening a ``draft 3`` key.
# Join with a newline (not a space) so a multi-line value keeps its
# line structure (A1); the HTML renderer converts these newlines to
# <br> for multiline fields like contact and notes.
if is_indented and current_key is not None:
if metadata[current_key]:
metadata[current_key] += "\n" + line
else:
metadata[current_key] = line
self.current_line += 1
continue
# A non-indented line with a colon (and not a scene heading) starts a
# new key. The scene-heading guard reuses SCENE_HEADING_PATTERN so it is
# case-insensitive and space-form aware (B3): a line like
# "int. house - day - 3:00 pm" contains a colon (from the time) but is a
# scene heading, so it must fall through to body classification rather
# than opening a bogus "int. house - day - 3" key.
if self._opens_title_page_key(line):
key, value = line.split(":", 1)
key = key.strip().lower()
value = value.strip()
if current_key and current_key in metadata:
# Finalize previous multi-line value
metadata[current_key] = metadata[current_key].strip()
current_key = key
metadata[key] = value
self.current_line += 1
continue
# Any other non-indented line ends the title page (A2): an unindented
# non-key line is body content, not a continuation of the prior value.
break
# Clean up any trailing multi-line value
if current_key and current_key in metadata:
metadata[current_key] = metadata[current_key].strip()
return metadata
def _opens_title_page_key(self, line: str) -> bool:
"""Whether a non-indented line opens a title-page key rather than being body.
A colon alone is not enough: ``FADE IN:`` and prose like ``He opens the card: a
threat.`` also contain a colon. A title-page key must (1) not be a scene heading,
(2) carry a non-empty value or be continued on an indented line (so a bare
``FADE IN:`` is excluded), and (3) name a recognized field or be a capitalized
label (so a lowercase sentence fragment is body prose, not a key).
Args:
line: The stripped line under consideration.
Returns:
True when the line should open a title-page key.
"""
if ":" not in line or self.SCENE_HEADING_PATTERN.match(line):
return False
raw_key, value = line.split(":", 1)
raw_key = raw_key.strip()
stripped_value = value.strip()
if not stripped_value and not self._next_line_is_indented():
return False
# A recognized field always opens a key (even a lowercase one like "draft date").
if raw_key.lower() in self.TITLE_PAGE_KEYS:
return True
# Otherwise accept a capitalized label (each word starts uppercase/digit). The spec
# accepts any colon-terminated key at the top, so a custom key with a sentence-like
# value is still a key; only lowercase prose (and empty-value transitions above) are
# excluded. This is the documented arbitrary-key ambiguity.
words = raw_key.split()
return bool(words) and all(word[0].isupper() or word[0].isdigit() for word in words)
def _next_line_is_indented(self) -> bool:
"""Whether the line after the current one is an indented value continuation (A2)."""
next_index = self.current_line + 1
if next_index >= len(self.lines):
return False
raw_next = self.lines[next_index]
leading_spaces = len(raw_next) - len(raw_next.lstrip(" "))
return raw_next.startswith("\t") or leading_spaces >= 3
@staticmethod
def _collapse_note_seam(match: re.Match[str]) -> str:
"""Replace a matched inline note (plus flanking whitespace) with the right spacing.
A note between two spaces collapses to a single space so the surrounding words do
not run together with a double space; a note flush against a word or the line edge
is removed with no inserted space.
Args:
match: A ``NOTE_SEAM_PATTERN`` match (the note and any adjacent spaces/tabs).
Returns:
A single space when the note was flanked by whitespace on both sides, else "".
"""
matched = match.group(0)
return " " if matched[:1] in (" ", "\t") and matched[-1:] in (" ", "\t") else ""
def _flush_open_note_as_text(self) -> None:
"""Break an open multi-line note and re-emit its buffered lines as action.
When a genuinely blank line interrupts a note that opened with ``[[`` but never
closed, the note does not survive as a single NOTE element (E7). The buffered
bracket lines fall back to action text instead. Empty buffered lines (left by
two-space connector lines under E6) carry no content and are dropped, so only
real bracket text is re-emitted.
This mutates parser state: it clears the open-note flag and buffer, then appends
one ACTION element per non-empty buffered line to :attr:`elements`.
"""
# The '[[' never closed, so record the diagnostic here: both the blank-line break
# and the end-of-input recovery flow through this method.
self.diagnostics.append(
ValidationIssue(
line_number=self.note_start_line,
severity="error",
code=CODE_UNCLOSED_NOTE,
message="Note opened with '[[' but never closed",
)
)
buffered_lines = self.note_buffer
note_start = self.note_start_line
self.in_note = False
self.note_buffer = []
for line_offset, buffered_line in enumerate(buffered_lines):
if not buffered_line.strip():
continue
element = FountainElement(
type=ElementType.ACTION,
text=buffered_line,
formatting=[],
line_number=note_start + line_offset,
)
self._finalize_inline(element)
self.elements.append(element)
def _parse_line(
self, line: str, had_blank_line_before: bool = False, raw_line: str | None = None
) -> FountainElement | None:
"""Parse a single line and return the appropriate FountainElement.
Classifies a single line of Fountain text into the appropriate element type using
regex pattern matching. Handles precedence rules and context-sensitive parsing
for elements like dialogue vs. action lines.
The parsing follows Fountain's precedence rules:
1. Forced elements (prefixed with !, @, >, .) take highest precedence
2. Special markers (boneyard, notes, page breaks) are checked early
3. Natural patterns (scene headings, characters, transitions) are matched
4. Context-dependent elements (dialogue, parentheticals) use previous elements
5. Default fallback is action text
Args:
line: The text line to parse (may include leading/trailing whitespace)
had_blank_line_before: Whether there was a blank line before this one,
affects dialogue continuation detection
raw_line: The untrimmed source line, when available. Used so the natural
transition rule can see trailing whitespace (D1); recursive
callers that pass a stripped remainder omit it.
Returns:
FountainElement instance for the parsed line, or None if line should be skipped
(e.g., inside boneyard comments, empty lines)
Examples:
Scene heading with scene number:
>>> parser = FountainParser()
>>> parser.current_line = 0
>>> element = parser._parse_line("INT. COFFEE SHOP - DAY #1#")
>>> element.type.value
'scene_heading'
>>> element.text
'INT. COFFEE SHOP - DAY'
>>> element.metadata['scene_number']
'1'
Forced character:
>>> parser.lines = ["@john", "Hello there"]
>>> parser.current_line = 0
>>> element = parser._parse_line("@john")
>>> element.type.value
'character'
>>> element.text
'john'
>>> element.metadata['forced']
True
Character with extension and dual dialogue:
>>> parser.lines = ["MARY (V.O.)^", "Hello there"]
>>> parser.current_line = 0
>>> element = parser._parse_line("MARY (V.O.)^")
>>> element.type.value
'character'
>>> element.text
'MARY'
>>> element.metadata['extension']
'V.O.'
>>> element.metadata['dual_dialogue']
True
Centered text:
>>> element = parser._parse_line(">THE END<")
>>> element.type.value
'centered'
>>> element.text
'THE END'
"""
original_line = line
line = line.strip()
if not line:
return None
# Handle multi-line boneyard comments
if self.in_boneyard:
close_index = line.find("*/")
if close_index != -1:
# The first */ closes the boneyard. Everything up to and including it is
# comment; the buffered pre-text (from the opening line) and the remainder
# after the */ rejoin on one line, since the boneyard removed the newlines
# between them.
self.in_boneyard = False
remainder = line[close_index + 2 :].strip()
pretext = self._boneyard_pretext
blank_before = self._boneyard_pretext_blank
self._boneyard_pretext = ""
combined = f"{pretext} {remainder}" if pretext and remainder else pretext or remainder
if combined:
return self._parse_line(combined, blank_before)
return None # Skip all lines inside boneyard
# Check for single-line boneyard (block comments) - handle before multiline start.
# A whole-line /* ... */ stays a BONEYARD element.
if self.BONEYARD_PATTERN.match(line):
return FountainElement(
type=ElementType.BONEYARD,
text=line,
formatting=[],
line_number=self.current_line + 1,
)
# A line containing /* somewhere other than a whole-line span. Two cases,
# both handled here so a line with leading body text never truncates the
# document. If a */ also appears on the line the span opens and closes
# here (e.g. "/* cut */ keep this"): strip the span and reprocess the
# surrounding remainder as body. Otherwise the /* opens a multi-line
# boneyard that closes on a later line.
open_index = line.find("/*")
if open_index != -1:
close_index = line.find("*/", open_index + 2)
if close_index != -1:
# Strip the span and rejoin. When both sides carry text the removed
# span leaves a whitespace seam ("Hello world."); collapse just that
# seam to a single space without touching internal whitespace elsewhere.
before = line[:open_index]
after = line[close_index + 2 :]
if before.strip() and after.strip():
remainder = f"{before.rstrip()} {after.lstrip()}".strip()
else:
remainder = (before + after).strip()
if remainder:
return self._parse_line(remainder, had_blank_line_before)
return None
# The /* opens a boneyard that does not close on this line. Anything
# before it (e.g. "He waves" in "He waves /* begin cut") is body and
# must be emitted; the /* and every following line is comment until a
# */ closes it. Reprocess the pre-text before entering boneyard state
# so the recursive call is not itself swallowed by the in_boneyard
# branch above. boneyard_start_line feeds the unclosed-boneyard
# diagnostic in validate() when no */ ever arrives.
self.boneyard_start_line = self.current_line + 1
# Buffer any pre-text so it rejoins the post-close remainder on one line rather
# than becoming a separate element with a spurious line break.
self._boneyard_pretext = line[:open_index].strip()
self._boneyard_pretext_blank = had_blank_line_before
self.in_boneyard = True
return None # Skip boneyard start line
# Handle multi-line notes
if self.in_note:
close_index = line.find("]]")
if close_index == -1:
# Still inside the note; the whole line is note content.
self.note_buffer.append(original_line)
return None
# The note closes here. Buffer only up to and including ']]'; any text after it
# is body content and must not be swallowed into the (hidden) note.
self.in_note = False
self.note_buffer.append(line[: close_index + 2])
note_element = FountainElement(
type=ElementType.NOTE,
text="\n".join(self.note_buffer),
formatting=[],
line_number=self.note_start_line,
)
remainder = line[close_index + 2 :].strip()
if remainder:
# Emit the note now, then reprocess the trailing body text on this line.
self._finalize_inline(note_element)
self.elements.append(note_element)
return self._parse_line(remainder, had_blank_line_before)
return note_element
# Check for page breaks
if self.PAGE_BREAK_PATTERN.match(line):
return FountainElement(
type=ElementType.PAGE_BREAK,
text=line,
formatting=[],
line_number=self.current_line + 1,
)
# Check for notes [[note]]
note_matches = list(self.NOTE_PATTERN.finditer(line))
# A whole-line note is a single complete [[...]] span covering the entire
# line. Testing only startswith("[[")/endswith("]]") is too loose: a line
# like "[[a]] middle [[b]]" also passes it while carrying real text between
# two separate notes. Requiring a fullmatch keeps that case out so it falls
# through to inline-note stripping (body rule 8) and the interior text
# classifies normally (E13).
if self.NOTE_PATTERN.fullmatch(line):
# Line is entirely a single note
return FountainElement(
type=ElementType.NOTE,
text=line,
formatting=[],
line_number=self.current_line + 1,
)
# Check for multi-line note start: line has [[ but no closing ]]
if "[[" in line and "]]" not in line:
open_index = line.find("[[")
# Classify any text before '[[' as body BEFORE entering note state, so the
# recursive call is not itself swallowed by the in_note branch above.
pre_text = line[:open_index].strip()
pre_element = self._parse_line(pre_text, had_blank_line_before) if pre_text else None
self.in_note = True
self.note_start_line = self.current_line + 1
# Buffer only the note portion (from '[['); the pre-text is already handled.
self.note_buffer = [line[open_index:]]
return pre_element
# Strip inline notes from the line text
if note_matches:
line = self.NOTE_SEAM_PATTERN.sub(self._collapse_note_seam, line).strip()
# Removing a note leaves a whitespace seam. Always drop the trailing
# seam. Only drop the leading seam when a note began the line (col 0 of
# the stripped line): a front note (e.g. "[[a]] middle") leaves a leading
# seam that is an artifact of the note. A line whose note is at the END
# (e.g. "\tIndented action [[note]]") must keep its deliberate leading
# indentation, so lstrip only when the first note starts the content.
note_starts_line = note_matches[0].start() == 0
original_line = self.NOTE_SEAM_PATTERN.sub(self._collapse_note_seam, original_line).rstrip()
if note_starts_line:
original_line = original_line.lstrip()
if not line:
return None
# Check for forced action (starts with !)
if self.FORCED_ACTION_PATTERN.match(line):
# Strip only the leading ``!`` forcing marker; the indentation that
# follows it is part of the action text (D9). Trailing whitespace is
# dropped. Leading tabs are retained verbatim (the spec keeps tabs in
# Action); the renderer converts them to spacing for display.
text = self.FORCED_ACTION_PATTERN.sub(r"\1", line).rstrip()
# Record the forced flag so the Fountain renderer re-emits the '!'. Without it a
# forced action whose text starts with '.'/'>'/etc. round-trips to a different
# element type.
forced_action_metadata: dict[str, MetadataValue] = {"forced": True}
return FountainElement(
type=ElementType.ACTION,
text=text,
formatting=[],
line_number=self.current_line + 1,
metadata=forced_action_metadata,
)
# Check for sections
if self.SECTION_PATTERN.match(line):
level = len(line) - len(line.lstrip("#"))
text = self.SECTION_PATTERN.sub("", line).strip()
return FountainElement(
type=ElementType.SECTION,
text=text,
formatting=[],
metadata={"level": level},
line_number=self.current_line + 1,
)
# Check for synopsis
if self.SYNOPSIS_PATTERN.match(line):
text = self.SYNOPSIS_PATTERN.sub("", line).strip()
return FountainElement(
type=ElementType.SYNOPSIS,
text=text,
formatting=[],
line_number=self.current_line + 1,
)
# Check for lyrics
lyrics_match = self.LYRICS_PATTERN.match(line)
if lyrics_match:
text = lyrics_match.group(1).strip()
return FountainElement(
type=ElementType.LYRICS,
text=text,
formatting=[],
line_number=self.current_line + 1,
)
# Check for forced scene heading (must come before natural scene heading)
if self.FORCED_SCENE_HEADING_PATTERN.match(line):
text = line[1:].strip() # Remove the '.'
metadata: dict[str, MetadataValue] = {"forced": True}
# Check for scene number
scene_num_match = self.SCENE_NUMBER_PATTERN.search(text)
if scene_num_match:
metadata["scene_number"] = scene_num_match.group(1).strip()
text = self.SCENE_NUMBER_PATTERN.sub("", text).strip()
return FountainElement(
type=ElementType.SCENE_HEADING,
text=text,
formatting=[],
line_number=self.current_line + 1,
metadata=metadata,
)
# Check for centered text (>text<) - must come before forced transition
if self.CENTERED_PATTERN.match(line):
text = line[1:-1].strip() # Remove the '>' and '<'
return FountainElement(
type=ElementType.CENTERED,
text=text,
formatting=[],
line_number=self.current_line + 1,
)
# Check for forced transition (>text - not enclosed)
if self.FORCED_TRANSITION_PATTERN.match(line) and not line.endswith("<"):
text = line[1:].strip() # Remove the '>'
# Record the forced flag so the Fountain renderer re-emits the '>'. Without it
# a forced transition whose text does not end in 'TO:' round-trips to ACTION.
forced_transition_metadata: dict[str, MetadataValue] = {"forced": True}
return FountainElement(
type=ElementType.TRANSITION,
text=text,
formatting=[],
line_number=self.current_line + 1,
metadata=forced_transition_metadata,
)
# Check for scene heading (requires a blank line before and after, or
# first/last element). The blank-line-after rule mirrors the transition
# branch below (B2): a natural heading immediately followed by a non-blank
# line is really action, so it falls through. Forced `.` headings are
# handled earlier and stay headings regardless of the following line.
if (
self.SCENE_HEADING_PATTERN.match(line)
and (had_blank_line_before or not self._has_visible_content())
and self._is_blank_line_after()
):
scene_metadata: dict[str, MetadataValue] = {}
text = line
# Check for scene number
scene_num_match = self.SCENE_NUMBER_PATTERN.search(text)
if scene_num_match:
scene_metadata["scene_number"] = scene_num_match.group(1).strip()
text = self.SCENE_NUMBER_PATTERN.sub("", text).strip()
return FountainElement(
type=ElementType.SCENE_HEADING,
text=text,
formatting=[],
line_number=self.current_line + 1,
metadata=scene_metadata,
)
# Check for transition (requires blank line before and after, or first/last element).
# D1: trailing whitespace after the colon defeats a natural transition. The pattern
# is end-anchored, so matching it against the raw (leading-stripped only) line makes
# `CUT TO: ` fall through to action. Recursive remainders carry no raw line and use
# the already-stripped `line`.
has_blank_before = had_blank_line_before or not self._has_visible_content()
transition_source = raw_line.lstrip() if raw_line is not None else line
if self.TRANSITION_PATTERN.match(transition_source) and has_blank_before and self._is_blank_line_after():
return FountainElement(
type=ElementType.TRANSITION,
text=line,
formatting=[],
line_number=self.current_line + 1,
)
# Check for forced character (@character)
if self.FORCED_CHARACTER_PATTERN.match(line):
character_name = self.FORCED_CHARACTER_PATTERN.sub(r"\1", line).strip()
# A trailing caret marks dual dialogue (C5), mirroring natural NAME^ cues.
# Strip the caret and the whitespace between the name and it, then flag the
# cue so _process_dual_dialogue pairs it with the preceding character block.
forced_metadata: dict[str, MetadataValue] = {"forced": True}
if character_name.endswith("^"):
character_name = character_name.rstrip("^").strip()
forced_metadata["dual_dialogue"] = True
# C7: lift a trailing "(extension)" off the forced name after the caret is
# handled, so "@McClane (O.S.)" and "@McClane (O.S.) ^" both keep only the bare
# name in the text with the extension in metadata, mirroring natural cues.
extension_match = self.FORCED_EXTENSION_PATTERN.match(character_name)
if extension_match:
character_name = extension_match.group(1).strip()
forced_metadata["extension"] = extension_match.group(2).strip()
# C6: the '@' is an explicit author override, so it forces a CHARACTER cue
# unconditionally. Unlike natural cues (which stay gated on the dialogue
# lookahead below), a forced cue is a cue whether dialogue, a blank line,
# action, or EOF follows; never demote it back to ACTION with the '@' kept.
# Spec: a character name must contain at least one alphabetical character, even
# when forced. A '@' cue with no letter (e.g. "@23") is not a valid character,
# so it falls through to normal classification (ultimately action).
if any(character.isalpha() for character in character_name):
return FountainElement(
type=ElementType.CHARACTER,
text=character_name,
formatting=[],
line_number=self.current_line + 1,
metadata=forced_metadata,
)
# Check for dual dialogue character (CHARACTER^); requires blank line before or first element.
# A scene-heading form (INT./EXT. ...) that degraded to here is action, never a cue (C1 guard).
if (
self.DUAL_CHARACTER_PATTERN.match(line)
and (had_blank_line_before or not self._has_visible_content())
and not self.SCENE_HEADING_PATTERN.match(line)
):
character_name = line.replace("^", "").strip()
if self._is_dialogue_following():
return FountainElement(
type=ElementType.CHARACTER,
text=character_name,
formatting=[],
line_number=self.current_line + 1,
metadata={"dual_dialogue": True},
)
# Check for character with extensions (CHARACTER (V.O.)); requires blank line before or first element
char_ext_match = self.CHARACTER_EXTENSION_PATTERN.match(line)
if (
char_ext_match
and (had_blank_line_before or not self._has_visible_content())
and not self.SCENE_HEADING_PATTERN.match(line)
):
character_name = char_ext_match.group(1).strip()
extension = char_ext_match.group(2).strip()
is_dual = char_ext_match.group(3) is not None
if self._is_dialogue_following():
char_metadata: dict[str, MetadataValue] = {"extension": extension}
if is_dual:
char_metadata["dual_dialogue"] = True
return FountainElement(
type=ElementType.CHARACTER,
text=character_name,
formatting=[],
line_number=self.current_line + 1,
metadata=char_metadata,
)
# Check for regular character (must be all caps); requires blank line before or first element.
# A scene-heading form (INT./EXT. ...) that degraded to here is action, never a cue (C1 guard).
if (
self.CHARACTER_PATTERN.match(line)
and (had_blank_line_before or not self._has_visible_content())
and not self.SCENE_HEADING_PATTERN.match(line)
):
# Look ahead to see if next line is dialogue or parenthetical
if self._is_dialogue_following():
cue_metadata: dict[str, MetadataValue] = {}
# Check if this character is continuing from a previous appearance
if self._is_character_continuation(line):
cue_metadata["continuation"] = True
return FountainElement(
type=ElementType.CHARACTER,
text=line,
formatting=[],
line_number=self.current_line + 1,
metadata=cue_metadata,
)
# An uppercase line that looks like a character cue but has no dialogue
# following is demoted to action below. Flag it as a diagnostic: a writer who
# meant it as a cue has an orphaned character with no lines. The diagnostic is
# surfaced on the parsed document and via validate().
self.diagnostics.append(
ValidationIssue(
line_number=self.current_line + 1,
severity="warning",
code=CODE_ORPHAN_CHARACTER_CUE,
message=f"Character cue '{line}' has no dialogue following it",
)
)
# Check if this is dialogue (follows character or parenthetical)
# BUT check for parenthetical first since it has higher precedence
if self._is_dialogue_line(had_blank_line_before):
# Check for parenthetical within dialogue context
if line.startswith("(") and line.endswith(")"):
return FountainElement(
type=ElementType.PARENTHETICAL,
text=line,
formatting=[],
line_number=self.current_line + 1,
)
# Otherwise it's regular dialogue
return FountainElement(
type=ElementType.DIALOGUE,
text=line,
formatting=[],
line_number=self.current_line + 1,
)
# Default to action
return FountainElement(
type=ElementType.ACTION,
# Preserve leading indentation verbatim, tabs included (the Fountain spec
# retains tabs and spaces in Action). The renderer converts a tab to spacing
# for display; spans index into this stored text, so a retained tab counts as
# one character.
text=original_line.rstrip(),
formatting=[],
line_number=self.current_line + 1,
)
def _is_dialogue_following(self) -> bool:
"""Check if the next non-empty line is dialogue.
Lookahead method that examines subsequent lines to determine if the current line
should be classified as a character name. This prevents false positive character
detection when ALL CAPS text appears in action lines.
The cue must be immediately followed by its dialogue: a blank line (or EOF)
directly after the cue disqualifies it (C3). Otherwise the method checks if
the immediate next line matches any structural element patterns. If no
structural patterns match, the line is considered potential dialogue,
confirming the current line as a character.
Returns:
bool: True if the next non-empty line appears to be dialogue, parenthetical,
or other non-structural text. False if it matches scene headings,
transitions, or other structural elements.
Examples:
>>> parser = FountainParser()
>>> parser.lines = ["JOHN", "Hello there", "How are you?"]
>>> parser.current_line = 0
>>> parser._is_dialogue_following()
True
>>> parser.lines = ["FADE IN", "INT. HOUSE - DAY"]
>>> parser.current_line = 0
>>> parser._is_dialogue_following()
False
Note:
This method is critical for distinguishing between character names and
action text that happens to be in ALL CAPS (like "FADE IN" or "THE END").
"""
# The cue must be immediately followed by its dialogue: a blank line (or
# EOF) directly after the cue disqualifies it (C3). Do not skip the blank
# to reach a later non-empty line, or "JOHN\n\nHe walks..." would wrongly
# treat JOHN as a cue for the later action.
if self._is_blank_line_after():
return False
next_line_idx = self.current_line + 1
while next_line_idx < len(self.lines):
next_line = self.lines[next_line_idx].strip()
if next_line:
# A hard-structural line (scene heading, transition, section, etc.)
# or standalone note is never this cue's dialogue.
is_hard_structural = any(pattern.match(next_line) for pattern in self.HARD_STRUCTURAL_PATTERNS)
is_standalone_note = next_line.startswith("[[") and next_line.endswith("]]")
if is_hard_structural or is_standalone_note:
return False
# An all-caps line that merely looks like a cue is only a rival cue
# (and thus not dialogue) if it is itself followed by its own dialogue.
# Otherwise it is dialogue for the preceding cue.
matches_cue = any(pattern.match(next_line) for pattern in self.CUE_PATTERNS)
return not (matches_cue and self._line_is_cue(next_line_idx))
next_line_idx += 1
return False
def _line_is_cue(self, line_idx: int) -> bool:
"""Check whether the line at ``line_idx`` is itself a real character cue.
A line is a real cue when its own next non-empty line is dialogue: not a
hard-structural element, not a standalone note, and not another line that
merely looks like a cue. This lets :meth:`_is_dialogue_following` tell a
genuine second cue (``JOHN`` then ``MARY`` then dialogue) apart from an
all-caps dialogue line (``JOHN`` then ``NO. NEVER.``).
Args:
line_idx: Index into ``self.lines`` of the candidate cue line.
Returns:
bool: True if the candidate line is followed by its own dialogue.
Examples:
>>> parser = FountainParser()
>>> parser.lines = ["JOHN", "MARY", "Hello there"]
>>> parser._line_is_cue(1)
True
>>> parser.lines = ["JOHN", "NO. NEVER."]
>>> parser._line_is_cue(1)
False
"""
scan_idx = line_idx + 1
while scan_idx < len(self.lines):
candidate = self.lines[scan_idx].strip()
if candidate:
is_hard_structural = any(pattern.match(candidate) for pattern in self.HARD_STRUCTURAL_PATTERNS)
is_standalone_note = candidate.startswith("[[") and candidate.endswith("]]")
matches_cue = any(pattern.match(candidate) for pattern in self.CUE_PATTERNS)
return not (is_hard_structural or is_standalone_note or matches_cue)
scan_idx += 1
return False
def _is_transparent_line(self, stripped_line: str) -> bool:
"""Whether a line is a whole-line note or boneyard (invisible for adjacency).
Notes ``[[...]]`` and boneyards ``/* ... */`` are stripped from output, so for
blank-line adjacency they are transparent: a scene heading or transition flush
against one is still preceded/followed by an effective blank line.
"""
return bool(self.NOTE_PATTERN.fullmatch(stripped_line) or self.BONEYARD_PATTERN.match(stripped_line))
def _has_visible_content(self) -> bool:
"""Whether any non-note/boneyard element has been emitted yet.
Used for 'is this the document start' checks: leading notes and boneyards are
invisible, so a heading after them is still effectively the first body element.
"""
return any(element.type not in (ElementType.NOTE, ElementType.BONEYARD) for element in self.elements)
def _is_blank_line_after(self) -> bool:
"""Check if there is a blank line (or EOF) after the current line.
Note and boneyard lines are skipped as transparent, so a heading followed by an
annotation and then a blank line still counts as having a blank line after it.
Returns:
bool: True if the next visible line is empty or we are at the end of the document.
"""
next_idx = self.current_line + 1
while next_idx < len(self.lines):
stripped = self.lines[next_idx].strip()
if not stripped:
return True
if self._is_transparent_line(stripped):
next_idx += 1
continue
return False
return True # EOF counts as blank after
def _is_dialogue_line(self, had_blank_line_before: bool = False) -> bool:
"""Check if current line is dialogue based on previous elements.
Determines if the current line should be classified as dialogue by examining
the context provided by previously parsed elements. Uses Fountain's dialogue
rules: dialogue always follows character names or parentheticals, and can
continue across multiple lines without blank line separation.
Args:
had_blank_line_before: Whether there was a blank line before this line.
Used to determine dialogue continuation vs new element.
Returns:
bool: True if this line should be classified as dialogue
Examples:
>>> parser = FountainParser()
>>> parser.elements = [FountainElement(ElementType.CHARACTER, "JOHN", [], 1)]
>>> parser._is_dialogue_line()
True
>>> parser.elements = [FountainElement(ElementType.ACTION, "John walks", [], 1)]
>>> parser._is_dialogue_line()
False
Dialogue continuation example:
>>> dialogue1 = FountainElement(ElementType.DIALOGUE, "Hello there.", [], 1)
>>> parser.elements = [FountainElement(ElementType.CHARACTER, "JOHN", [], 1), dialogue1]
>>> parser._is_dialogue_line(had_blank_line_before=False) # Continuation
True
>>> parser._is_dialogue_line(had_blank_line_before=True) # New element
False
Note:
This method implements Fountain's dialogue continuation rules where dialogue
can span multiple lines as long as there are no blank lines between them.
"""
if not self.elements:
return False
prev_element = self.elements[-1]
# Dialogue follows a CHARACTER or PARENTHETICAL cue, but only when it comes
# immediately after (no blank line between). Per the Fountain spec a blank line
# ends the dialogue block, so the not-had_blank_line_before guard applies to any
# CHARACTER/PARENTHETICAL block, natural or forced. A natural cue can reach here
# with a blank line after (e.g. JOHN / (softly) / blank / Hi.: prev is
# PARENTHETICAL and had_blank_line_before is True), and Hi. correctly classifies
# as action. It also covers forced '@' cues (C6), which force a CHARACTER even
# with no dialogue after them: a line past the blank is action, not dialogue.
if prev_element.type in (ElementType.CHARACTER, ElementType.PARENTHETICAL) and not had_blank_line_before:
return True
# Dialogue continuation: follows DIALOGUE with NO blank line separation
if prev_element.type == ElementType.DIALOGUE and not had_blank_line_before:
return True
# A lyric, standalone note, or boneyard comment inside a dialogue block does not end
# the block: a character who sings (or carries an editorial note or a commented-out
# line) mid-speech and then keeps speaking stays in dialogue. Look back past those
# non-terminating lines to the element that anchors the block; a blank line still
# ends it (had_blank_line_before).
non_terminating = (ElementType.LYRICS, ElementType.NOTE, ElementType.BONEYARD)
if prev_element.type in non_terminating and not had_blank_line_before:
for earlier in reversed(self.elements):
if earlier.type in non_terminating:
continue
return earlier.type in (
ElementType.CHARACTER,
ElementType.PARENTHETICAL,
ElementType.DIALOGUE,
)
return False
def _is_character_continuation(self, character_name: str) -> bool:
"""Check if this character is continuing from a previous appearance.
Determines if a character's dialogue is a continuation from an earlier scene
within the same sequence. This is used to detect when a character returns
to speaking after action lines, which may warrant a (CONT'D) extension in
some screenplay formats.
The method searches backwards for the most recent appearance of the same
character, then checks if there has been intervening action without scene
breaks. Scene headings reset the continuation context.
Args:
character_name: The character name to check for continuation
Returns:
bool: True if this character spoke earlier in the same scene with
intervening action, False otherwise
Examples:
>>> parser = FountainParser()
>>> char1 = FountainElement(ElementType.CHARACTER, "JOHN", [], 1)
>>> dialogue1 = FountainElement(ElementType.DIALOGUE, "Hello.", [], 2)
>>> action = FountainElement(ElementType.ACTION, "John stands up.", [], 3)
>>> parser.elements = [char1, dialogue1, action]
>>> parser._is_character_continuation("JOHN")
True
>>> # With scene break - no continuation
>>> scene = FountainElement(ElementType.SCENE_HEADING, "INT. KITCHEN - DAY", [], 4)
>>> parser.elements = [char1, dialogue1, action, scene]
>>> parser._is_character_continuation("JOHN")
False
>>> # Different character - no continuation
>>> parser.elements = [char1, dialogue1, action]
>>> parser._is_character_continuation("MARY")
False
Note:
This method helps identify when screenwriting software might automatically
add (CONT'D) extensions to character names, though fountain-py doesn't
automatically add these extensions.
"""
# Walk back to the most recent character cue, collecting the element types emitted
# since it. Continuation needs the same speaker, intervening action, and no scene
# heading in between (a heading is a scene break).
types_since_cue: set[ElementType] = set()
for element in reversed(self.elements):
if element.type == ElementType.CHARACTER:
return (
element.text.strip() == character_name.strip()
and ElementType.ACTION in types_since_cue
and ElementType.SCENE_HEADING not in types_since_cue
)
types_since_cue.add(element.type)
return False
@staticmethod
def _strip_escapes(text: str) -> str:
"""Replace backslash escapes with their literal characters."""
if "\\" not in text:
return text
return text.replace("\\*", "*").replace("\\_", "_").replace("\\\\", "\\")
def _finalize_inline(self, element: FountainElement) -> None:
"""Resolve inline markup on an element in place before it is stored (D4).
Elements leave :meth:`_parse_line` with raw text and empty formatting; this is
the single place emphasis is resolved. Emphasis-bearing elements have their
emphasis delimiters stripped and their formatting derived as content-only spans
over the clean text. Verbatim
elements (BONEYARD, NOTE, PAGE_BREAK) and structural cues keep their text and
only get backslash escapes resolved to literal characters.
Args:
element: The parsed element to finalize; mutated in place.
"""
if element.type in self.EMPHASIS_TYPES:
element.text, element.formatting = self._extract_inline(element.text)
else:
element.text = self._strip_escapes(element.text)
# Characters treated as punctuation for CommonMark-style flanking decisions.
_PUNCTUATION: frozenset[str] = frozenset(string.punctuation)
@staticmethod
def _is_left_flanking(before: str | None, after: str | None) -> bool:
"""Whether a delimiter run can begin emphasis (content follows it).
Left-flanking means the run is not followed by whitespace, and either the
following character is not punctuation or the preceding character is
whitespace/punctuation/boundary. Boundaries (``None``) count as whitespace.
"""
if after is None or after.isspace():
return False
if after not in FountainParser._PUNCTUATION:
return True
return before is None or before.isspace() or before in FountainParser._PUNCTUATION
@staticmethod
def _is_right_flanking(before: str | None, after: str | None) -> bool:
"""Whether a delimiter run can end emphasis (content precedes it)."""
if before is None or before.isspace():
return False
if before not in FountainParser._PUNCTUATION:
return True
return after is None or after.isspace() or after in FountainParser._PUNCTUATION
def _scan_delimiter_runs(self, formatting_text: str) -> list[_DelimiterRun]:
"""Split the text into maximal ``*`` / ``_`` runs with open/close eligibility.
Applies flanking rules to each run. Underscores additionally follow Markdown's
intraword rule (a ``_`` bordered by word characters on both sides neither opens
nor closes), so ``some_variable_name`` keeps its underscores.
Args:
formatting_text: Text with backslash escapes replaced by placeholder chars.
Returns:
The delimiter runs in left-to-right order.
"""
runs: list[_DelimiterRun] = []
index = 0
length = len(formatting_text)
while index < length:
char = formatting_text[index]
if char not in ("*", "_"):
index += 1
continue
end = index
while end < length and formatting_text[end] == char:
end += 1
before = formatting_text[index - 1] if index > 0 else None
after = formatting_text[end] if end < length else None
left_flank = self._is_left_flanking(before, after)
right_flank = self._is_right_flanking(before, after)
if char == "*":
can_open, can_close = left_flank, right_flank
else:
before_punct = before is not None and before in self._PUNCTUATION
after_punct = after is not None and after in self._PUNCTUATION
can_open = left_flank and (not right_flank or before_punct)
can_close = right_flank and (not left_flank or after_punct)
runs.append(_DelimiterRun(index, char, end - index, can_open, can_close))
index = end
return runs
def _find_emphasis_spans(self, formatting_text: str) -> list[FormatSpan]:
"""Find full-match emphasis spans, scanning each line independently.
Emphasis is not carried across line breaks (Fountain spec), and a merged action
paragraph carries embedded newlines, so each ``\\n``-delimited segment is scanned
on its own and its spans are offset back into the full text.
Args:
formatting_text: Text with backslash escapes replaced by placeholder chars.
Returns:
List of full-match FormatSpan objects indexed into ``formatting_text``.
"""
spans: list[FormatSpan] = []
offset = 0
for segment in formatting_text.split("\n"):
for span in self._find_emphasis_spans_in_line(segment):
spans.append(FormatSpan(span.start + offset, span.end + offset, span.format_type))
offset += len(segment) + 1
return spans
def _find_emphasis_spans_in_line(self, formatting_text: str) -> list[FormatSpan]:
"""Match emphasis within a single line (no newlines) via a delimiter stack.
Scans left to right, pushing runs that can open onto a stack and matching each
run that can close against the nearest compatible opener. Bold-italic (``***``),
bold (``**``), and italic (``*``) draw from the same asterisk runs, so a run can
act as both a closer and an opener (``**bold***italic*``); left/right consumption
is tracked per run so each span's offsets bracket exactly its own delimiters.
Unmatched delimiters are left in place and stay literal.
Args:
formatting_text: A single newline-free line with escapes already replaced.
Returns:
List of full-match FormatSpan objects indexed into ``formatting_text``.
"""
spans: list[FormatSpan] = []
openers: list[_DelimiterRun] = []
for run in self._scan_delimiter_runs(formatting_text):
if run.can_close:
self._close_against_openers(run, openers, spans)
if run.available > 0 and run.can_open:
openers.append(run)
return spans
def _close_against_openers(
self, closer: _DelimiterRun, openers: list[_DelimiterRun], spans: list[FormatSpan]
) -> None:
"""Match a closing run against the nearest compatible opener(s), emitting spans.
Consumes as many delimiters as pair up: bold-italic (3), bold (2), or italic (1)
for asterisks, always underline (1) for underscores. Openers nested inside a
newly matched pair are discarded so they cannot span across it.
Args:
closer: The run that can close emphasis; mutated as its delimiters are used.
openers: Stack of still-open runs; matched/exhausted entries are removed.
spans: Accumulator that receives each full-match span produced here.
"""
while closer.available > 0:
match_index = next(
(
index
for index in reversed(range(len(openers)))
if openers[index].char == closer.char and openers[index].available > 0
),
None,
)
if match_index is None:
return
opener = openers[match_index]
del openers[match_index + 1 :]
width: int
format_type: FormatType
if closer.char == "_":
width, format_type = 1, "underline"
else:
pair = min(opener.available, closer.available)
if pair >= 3:
width, format_type = 3, "bold_italic"
elif pair >= 2:
width, format_type = 2, "bold"
else:
width, format_type = 1, "italic"
start = opener.position + opener.length - opener.right_used - width
opener.right_used += width
end = closer.position + closer.left_used + width
closer.left_used += width
spans.append(FormatSpan(start, end, format_type))
if opener.available == 0:
openers.pop(match_index)
def _extract_inline(self, text: str) -> tuple[str, list[FormatSpan]]:
"""Resolve inline markup: strip emphasis delimiters and return content spans (D4).
Produces the clean text a downstream consumer stores on the element, plus
FormatSpan entries whose start/end index into that clean text and cover only the
emphasized content (delimiters removed). Backslash escapes (``\\*``, ``\\_``,
``\\\\``) are resolved to their literal characters and never treated as emphasis,
so ``\\*literal\\*`` keeps its asterisks and produces no span.
Formatting precedence (highest to lowest): bold-italic (``***``), bold (``**``),
italic (``*``), underline (``_``).
Args:
text: Raw text to scan for emphasis markers and escapes.
Returns:
A ``(clean_text, spans)`` tuple. ``clean_text`` has escapes resolved and
emphasis delimiters removed; ``spans`` index into ``clean_text``.
Examples:
>>> parser = FountainParser()
>>> clean, spans = parser._extract_inline("This is **bold** text.")
>>> clean
'This is bold text.'
>>> spans[0].format_type, spans[0].start, spans[0].end
('bold', 8, 12)
>>> clean[spans[0].start:spans[0].end]
'bold'
"""
# Replace escapes with single-char placeholders so they neither match emphasis
# patterns nor shift positions relative to the resolved literals (each is one char).
formatting_text = text.replace("\\*", "\x00").replace("\\_", "\x01").replace("\\\\", "\x02")
full_spans = self._find_emphasis_spans(formatting_text)
# Mark the delimiter characters (both sides of every span) for deletion.
delete_indices: set[int] = set()
for span in full_spans:
width = self.DELIMITER_WIDTHS[span.format_type]
delete_indices.update(range(span.start, span.start + width))
delete_indices.update(range(span.end - width, span.end))
# kept_before[index] == number of retained characters before that index, giving
# the mapping from a formatting_text offset to its clean-text offset.
kept_before = [0] * (len(formatting_text) + 1)
for index in range(len(formatting_text)):
kept_before[index + 1] = kept_before[index] + (0 if index in delete_indices else 1)
content_spans: list[FormatSpan] = []
for span in full_spans:
width = self.DELIMITER_WIDTHS[span.format_type]
content_start = kept_before[span.start + width]
content_end = kept_before[span.end - width]
content_spans.append(FormatSpan(content_start, content_end, span.format_type))
# Rebuild the clean text: drop delimiter chars, restore escaped literals.
kept_chars = [char for index, char in enumerate(formatting_text) if index not in delete_indices]
clean_text = "".join(kept_chars).replace("\x00", "*").replace("\x01", "_").replace("\x02", "\\")
return clean_text, content_spans
@staticmethod
def _is_dialogue_block(element: FountainElement) -> bool:
"""Whether an element belongs to the dialogue that follows a character cue."""
return element.type in (ElementType.DIALOGUE, ElementType.PARENTHETICAL)
def _process_dual_dialogue(self) -> None:
"""Post-process elements to pair dual dialogue characters and their dialogue.
Identifies characters marked with dual dialogue (^) and pairs them with the
immediately preceding character and their respective dialogue blocks. Creates
DUAL_DIALOGUE elements that contain both character/dialogue pairs for
side-by-side rendering.
The algorithm:
1. Finds characters marked with dual_dialogue metadata (ending with ^)
2. Locates the most recent previous character (must be adjacent)
3. Collects dialogue and parentheticals for both characters
4. Creates a single DUAL_DIALOGUE element containing both character blocks
5. Replaces the original elements with the dual dialogue element
Examples:
Before processing:
- CHARACTER: "JOHN"
- DIALOGUE: "Hello there."
- CHARACTER: "MARY" (metadata: dual_dialogue=True)
- DIALOGUE: "Hi back!"
After processing:
- DUAL_DIALOGUE: metadata contains left_character, left_dialogue,
right_character, right_dialogue for simultaneous rendering
Note:
Only processes characters that are immediately adjacent (no scene headings
or action lines between them). Characters separated by structural elements
are not paired as dual dialogue.
"""
index = 0
while index < len(self.elements):
element = self.elements[index]
# Look for characters marked as dual dialogue
if element.type == ElementType.CHARACTER and element.metadata.get("dual_dialogue"):
# Find the previous character; a scene heading or action in between means
# there is no adjacent cue to pair with.
prev_char_idx = None
for earlier_index in range(index - 1, -1, -1):
earlier_type = self.elements[earlier_index].type
if earlier_type == ElementType.CHARACTER:
prev_char_idx = earlier_index
break
if earlier_type in (ElementType.SCENE_HEADING, ElementType.ACTION):
break
if prev_char_idx is not None:
# Collect the dialogue and parentheticals that follow each cue
prev_dialogue = list(takewhile(self._is_dialogue_block, self.elements[prev_char_idx + 1 : index]))
curr_dialogue = list(takewhile(self._is_dialogue_block, self.elements[index + 1 :]))
# Create dual dialogue element
if prev_dialogue and curr_dialogue:
# The left character anchors the pair; clear any caret flag it
# carries (a both-caret pair) so it is not left latently dual.
left_character = self.elements[prev_char_idx]
left_character.metadata.pop("dual_dialogue", None)
dual_element = FountainElement(
type=ElementType.DUAL_DIALOGUE,
text="", # Dual dialogue doesn't have direct text
formatting=[],
line_number=element.line_number,
metadata={
"left_character": left_character,
"left_dialogue": prev_dialogue,
"right_character": element,
"right_dialogue": curr_dialogue,
},
)
# Replace the range with the dual dialogue element and resume after it
self.elements[prev_char_idx : index + len(curr_dialogue) + 1] = [dual_element]
index = prev_char_idx + 1
continue
# A dual-flagged cue that never paired (a lone '^', or no dialogue on both
# sides) is not dual: clear the latent flag so a non-dual character is not
# left marked as dual dialogue.
element.metadata.pop("dual_dialogue", None)
index += 1