Source code for fountain.renderer

# ABOUTME: HTML and Fountain-format renderers for parsed screenplay documents.
# Converts FountainDocument/FountainElement trees into formatted HTML or round-trip Fountain markup.
"""
Renderers for Fountain documents.

This module provides renderers for converting parsed Fountain documents into various
output formats. The primary renderers are HTMLRenderer for web display and
FountainRenderer for round-trip conversion back to Fountain markup.

Classes:
    HTMLRenderer: Converts Fountain documents to HTML with screenplay formatting.
    FountainRenderer: Converts Fountain documents back to Fountain markup format.

Example:
    Basic rendering of a Fountain document to HTML:

    >>> from fountain.parser import FountainParser
    >>> from fountain.renderer import HTMLRenderer
    >>> parser = FountainParser()
    >>> doc = parser.parse("INT. HOUSE - DAY\\n\\nAction here.")
    >>> renderer = HTMLRenderer()
    >>> html = renderer.render(doc)
    >>> '<div class="fountain-scene-heading">INT. HOUSE - DAY</div>' in html
    True
"""

from __future__ import annotations

import html as html_module
from typing import TYPE_CHECKING

from fountain.elements import ElementType, FormatSpan, FormatType, FountainElement

if TYPE_CHECKING:
    from collections.abc import Callable, Mapping

    from fountain.document import FountainDocument

DEFAULT_CSS = """\
.fountain-script {
    font-family: 'Courier New', 'Courier', monospace;
    font-size: 12pt;
    line-height: 1.2;
    max-width: 70%;
    margin: 0 auto;
    padding: 1in;
    background: white;
    color: black;
}

.fountain-title-page {
    text-align: center;
    margin-bottom: 3in;
}

.fountain-title-page .fountain-title {
    font-size: 24pt;
    font-weight: bold;
    margin-bottom: 1in;
    text-transform: uppercase;
}

.fountain-title-page .fountain-author {
    font-size: 14pt;
    margin-bottom: 0.5in;
}

.fountain-title-page .fountain-credit,
.fountain-title-page .fountain-source,
.fountain-title-page .fountain-draft-date,
.fountain-title-page .fountain-contact,
.fountain-title-page .fountain-writers,
.fountain-title-page .fountain-producer,
.fountain-title-page .fountain-director,
.fountain-title-page .fountain-date,
.fountain-title-page .fountain-revised,
.fountain-title-page .fountain-version,
.fountain-title-page .fountain-format,
.fountain-title-page .fountain-created,
.fountain-title-page .fountain-copyright,
.fountain-title-page .fountain-notes {
    font-size: 12pt;
    margin-bottom: 0.25in;
}

.fountain-title-page .fountain-notes,
.fountain-title-page .fountain-contact {
    white-space: pre-line;
}

.fountain-script-body {
    page-break-before: always;
}

.fountain-scene-heading {
    font-weight: bold;
    text-transform: uppercase;
    margin-top: 2em;
    margin-bottom: 1em;
}

.fountain-action {
    margin-bottom: 1em;
    text-align: left;
    white-space: pre-wrap;
}

.fountain-character {
    text-align: center;
    font-weight: bold;
    text-transform: uppercase;
    margin-top: 1em;
    margin-bottom: 0;
}

.fountain-dialogue {
    text-align: center;
    margin: 0 auto 1em auto;
}

.fountain-parenthetical {
    text-align: center;
    font-style: italic;
    margin: 0 auto;
}

.fountain-transition {
    text-align: right;
    font-weight: bold;
    text-transform: uppercase;
    margin-top: 1em;
    margin-bottom: 1em;
}

.fountain-scene-number {
    font-weight: normal;
    color: #888;
    font-size: 10pt;
}

.fountain-character-extension,
.fountain-character-continuation {
    font-weight: normal;
    font-size: 10pt;
}

.fountain-dual-dialogue {
    display: flex;
    margin: 1em 0;
}

.fountain-dual-dialogue-left,
.fountain-dual-dialogue-right {
    flex: 1;
    padding: 0 1em;
}

.fountain-dual-dialogue-left {
    border-right: 1px solid #ddd;
}

.fountain-page-break {
    page-break-before: always;
    border-top: 2px solid #ccc;
    margin: 2em 0;
    height: 0;
}

.fountain-centered {
    text-align: center;
    margin: 1em 0;
}

.fountain-lyrics {
    text-align: center;
    font-style: italic;
    margin: 0.5em auto;
    color: #444;
}"""


# Each entry: (metadata_key, css_class, html_tag, prefix, multiline)
TITLE_PAGE_FIELD_ORDER: list[tuple[str, str, str, str, bool]] = [
    ("title", "fountain-title", "h1", "", False),
    ("author", "fountain-author", "p", "by ", False),
    ("authors", "fountain-author", "p", "by ", False),
    ("credit", "fountain-credit", "p", "", False),
    ("source", "fountain-source", "p", "", False),
    ("writers", "fountain-writers", "p", "Writers: ", False),
    ("producer", "fountain-producer", "p", "Producer: ", False),
    ("director", "fountain-director", "p", "Director: ", False),
    ("draft date", "fountain-draft-date", "p", "", False),
    ("date", "fountain-date", "p", "", False),
    ("revised", "fountain-revised", "p", "Revised: ", False),
    ("version", "fountain-version", "p", "Version: ", False),
    ("format", "fountain-format", "p", "Format: ", False),
    ("created", "fountain-created", "p", "Created: ", False),
    ("contact", "fountain-contact", "p", "", True),
    ("copyright", "fountain-copyright", "p", "", False),
    ("notes", "fountain-notes", "p", "", True),
]

# Set of known field keys for quick lookup
_KNOWN_TITLE_FIELDS = {field[0] for field in TITLE_PAGE_FIELD_ORDER}

# Element types that are writer-only tools and render to nothing in formatted output.
_WRITER_TOOL_TYPES = (ElementType.NOTE, ElementType.BONEYARD, ElementType.SECTION, ElementType.SYNOPSIS)


def _wrap_spans(
    text: str,
    formatting: list[FormatSpan],
    open_markup: Mapping[FormatType, str],
    close_markup: Mapping[FormatType, str],
    escape: Callable[[str], str],
) -> str:
    """Wrap formatted spans of ``text`` in open/close markup, escaping every character.

    Spans may overlap or nest (e.g. an underlined phrase containing an italic span), so
    a single-pass segment builder that assumes non-overlapping spans would duplicate the
    shared content. Instead, sweep character by character, tracking which format types
    cover each position, and open/close markup with a stack so the output is always
    well-formed and properly nested (D6). Where two spans only partially overlap, the
    outer-starting span stays outermost and the inner is split at the boundary, so no
    character is duplicated or dropped.

    Args:
        text: The clean element text the spans index into.
        formatting: Content-only spans over ``text``.
        open_markup: Markup emitted when a format type opens.
        close_markup: Markup emitted when a format type closes.
        escape: Transform applied to each character of ``text``.

    Returns:
        The escaped text with markup around every span.

    Example:
        >>> _wrap_spans("a b", [FormatSpan(2, 3, "bold")], {"bold": "["}, {"bold": "]"}, str.upper)
        'A [B]'
    """
    result_parts: list[str] = []
    open_stack: list[FormatType] = []

    for position, char in enumerate(text):
        # Sort covering spans outermost-first: earlier start wins, then wider span,
        # then a stable format order so nesting is deterministic across positions.
        covering = sorted(
            (span for span in formatting if span.start <= position < span.end),
            key=lambda span: (span.start, -(span.end - span.start), span.format_type),
        )
        desired = [span.format_type for span in covering]

        # Keep the longest prefix of already-open markup that still matches the
        # desired nesting; close everything opened above it.
        common = 0
        while common < len(open_stack) and common < len(desired) and open_stack[common] == desired[common]:
            common += 1
        while len(open_stack) > common:
            result_parts.append(close_markup[open_stack.pop()])

        for format_type in desired[common:]:
            result_parts.append(open_markup[format_type])
            open_stack.append(format_type)

        result_parts.append(escape(char))

    while open_stack:
        result_parts.append(close_markup[open_stack.pop()])

    return "".join(result_parts)


[docs] class HTMLRenderer: """Renders Fountain documents as HTML with screenplay formatting. The HTMLRenderer converts parsed Fountain documents into HTML with proper screenplay formatting, including CSS styles that follow industry-standard screenplay layout conventions. It handles all Fountain element types and preserves formatting marks like bold, italic, and underline. The renderer provides three output modes: - ``render(doc)`` — Pure HTML fragment for embedding (no CSS) - ``render_page(doc)`` — Standalone HTML with embedded CSS - ``get_css()`` — Raw CSS string for external stylesheet use CSS Classes Generated: - fountain-script: Main container for the entire screenplay - fountain-title-page: Container for title page metadata - fountain-script-body: Container for the main screenplay content - fountain-scene-heading: Scene headers (INT/EXT) - fountain-action: Action/description paragraphs - fountain-character: Character names before dialogue - fountain-dialogue: Spoken dialogue text - fountain-parenthetical: Stage directions within dialogue - fountain-transition: Scene transitions (CUT TO:, etc.) - fountain-dual-dialogue: Container for simultaneous dialogue - fountain-page-break: Page break markers - fountain-centered: Centered text - fountain-lyrics: Song lyrics with special formatting Notes, sections, synopses, and boneyard are writer tools. They are hidden from the formatted output by default and emit no markup, so no CSS classes are generated for them. Example: Render a simple screenplay to HTML: >>> from fountain.parser import FountainParser >>> from fountain.renderer import HTMLRenderer >>> parser = FountainParser() >>> doc = parser.parse('''Title: My Screenplay ... Author: Jane Doe ... ... INT. COFFEE SHOP - DAY ... ... JOHN enters, looking tired. ... ... JOHN ... I need coffee. ... ''') >>> renderer = HTMLRenderer() >>> html = renderer.render(doc) >>> '<h1 class="fountain-title">My Screenplay</h1>' in html True >>> '<div class="fountain-character">JOHN</div>' in html True """
[docs] def render(self, document: FountainDocument) -> str: """Render a FountainDocument as an HTML fragment. Returns a pure HTML fragment suitable for embedding in web pages, documentation systems, or any context where CSS is managed externally. Use ``render_page()`` for standalone HTML with embedded CSS. Args: document: The FountainDocument to render, containing parsed elements and optional title page metadata. Returns: An HTML fragment string containing the screenplay markup wrapped in a ``<div class="fountain-script">`` container. Does not include ``<style>`` tags or CSS. Example: >>> from fountain.parser import FountainParser >>> parser = FountainParser() >>> doc = parser.parse("INT. ROOM - DAY\\n\\nAction here.") >>> renderer = HTMLRenderer() >>> html = renderer.render(doc) >>> '<div class="fountain-script">' in html True >>> '<style>' not in html True """ html_parts = [] # Add document wrapper html_parts.append('<div class="fountain-script">') # Add title page if metadata exists if document.metadata: html_parts.append(self._render_title_page(document.metadata)) # Add script body html_parts.append('<div class="fountain-script-body">') for element in document.elements: html_parts.append(self._render_element(element)) html_parts.append("</div>") # script-body html_parts.append("</div>") # fountain-script return "\n".join(html_parts)
[docs] def render_page(self, document: FountainDocument) -> str: """Render a FountainDocument as a standalone HTML page with embedded CSS. Returns a complete HTML string with a ``<style>`` block and the screenplay fragment. Suitable for saving as a self-contained HTML file. Args: document: The FountainDocument to render. Returns: A complete HTML string with embedded CSS styling followed by the screenplay markup. Example: >>> from fountain.parser import FountainParser >>> parser = FountainParser() >>> doc = parser.parse("INT. ROOM - DAY\\n\\nAction here.") >>> renderer = HTMLRenderer() >>> html = renderer.render_page(doc) >>> '<style>' in html True >>> '<div class="fountain-script">' in html True """ css_block = f"<style>\n{DEFAULT_CSS}\n</style>" fragment = self.render(document) return f"{css_block}\n{fragment}"
[docs] def get_css(self) -> str: """Return the raw CSS string for screenplay formatting. Returns the CSS rules without ``<style>`` tags, so consumers can inject the styles however they need (e.g., into an external stylesheet, a ``<link>`` tag, or a build system's CSS pipeline). Returns: A string containing CSS rules for all Fountain element types. Example: >>> renderer = HTMLRenderer() >>> css = renderer.get_css() >>> '.fountain-script' in css True >>> '<style>' not in css True """ return DEFAULT_CSS
def _render_title_page(self, metadata: dict[str, str]) -> str: """Render the title page metadata as HTML. Converts title page metadata fields into formatted HTML elements. Handles all standard Fountain title page fields including title, author(s), credit, source, contact information, and dates. Args: metadata: Dictionary of title page fields and their values. Common fields include: title, author/authors, credit, source, draft date, contact, copyright, notes, etc. Returns: HTML string representing the formatted title page with appropriate CSS classes for each metadata field. Note: Multi-line fields like 'contact' and 'notes' are preserved with line breaks converted to HTML <br> tags. """ html_parts = ['<div class="fountain-title-page">'] # Render known fields in defined order. 'author' and 'authors' each render as their # own author paragraph (Q10); the two renderers agree that both keys are represented. for key, css_class, tag, prefix, multiline in TITLE_PAGE_FIELD_ORDER: if key not in metadata: continue value_html = self._escape_html(metadata[key]) if multiline: value_html = value_html.replace("\n", "<br>") html_parts.append(f'<{tag} class="{css_class}">{prefix}{value_html}</{tag}>') # Render any custom/unknown metadata fields after known fields. The key is # attacker-controlled (arbitrary title-page keys are supported), so escape it in # both the class attribute and the label text, exactly as the value is escaped. for key, value in metadata.items(): if key in _KNOWN_TITLE_FIELDS: continue css_class = self._escape_html(key.replace(" ", "-")) field_label = self._escape_html(key.replace("_", " ").title()) value_html = self._escape_html(value).replace("\n", "<br>") html_parts.append(f'<p class="fountain-custom-field {css_class}">{field_label}: {value_html}</p>') html_parts.append("</div>") return "\n".join(html_parts) def _render_element(self, element: FountainElement) -> str: """Render a single FountainElement as HTML. Converts a parsed Fountain element into its HTML representation with appropriate CSS classes and formatting. Handles all element types including scenes, dialogue, action, transitions, and special elements like dual dialogue and page breaks. Args: element: The FountainElement to render, containing type, text, formatting spans, and optional metadata. Returns: HTML string for the element with appropriate CSS classes and structure. Includes any inline formatting (bold, italic, underline) and element-specific features like scene numbers or character extensions. Example: >>> from fountain.elements import FountainElement, ElementType >>> element = FountainElement( ... type=ElementType.CHARACTER, ... text="SARAH", ... formatting=[], ... line_number=1, ... metadata={"extension": "V.O."} ... ) >>> renderer = HTMLRenderer() >>> html = renderer._render_element(element) >>> expected = '<div class="fountain-character">SARAH ' >>> expected += '<span class="fountain-character-extension">(V.O.)</span></div>' >>> expected == html True """ text = self._apply_formatting(element.text, element.formatting) match element.type: case ElementType.SCENE_HEADING: scene_html = f'<div class="fountain-scene-heading">{text}' if "scene_number" in element.metadata: scene_number = self._escape_html(str(element.metadata["scene_number"])) scene_html += f' <span class="fountain-scene-number">#{scene_number}#</span>' return f"{scene_html}</div>" case ElementType.ACTION: # Convert tabs to spaces and preserve leading whitespace text_with_spacing = text.replace("\t", "&nbsp;&nbsp;&nbsp;&nbsp;") text_with_br = text_with_spacing.replace("\n", "<br>") return f'<div class="fountain-action">{text_with_br}</div>' case ElementType.CHARACTER: char_html = f'<div class="fountain-character">{text}' if "extension" in element.metadata: extension = self._escape_html(str(element.metadata["extension"])) char_html += f' <span class="fountain-character-extension">({extension})</span>' elif element.metadata.get("continuation"): char_html += ' <span class="fountain-character-continuation">(CONT\'D)</span>' return f"{char_html}</div>" case ElementType.NOTE | ElementType.BONEYARD | ElementType.SECTION | ElementType.SYNOPSIS: # Notes, boneyard, sections, and synopses are writer-only tools omitted from # formatted output entirely, so they never ship in either the fragment or the # standalone page (E5, E11). Single-line boneyards stay consistent with # multi-line ones, whose interior lines the parser already drops. return "" case ElementType.DUAL_DIALOGUE: return self._render_dual_dialogue(element) case ElementType.PAGE_BREAK: return '<div class="fountain-page-break"></div>' case _: # Dialogue, parenthetical, transition, centered, and lyrics are a styled # div whose class is the element type. css_class = element.type.value.replace("_", "-") return f'<div class="fountain-{css_class}">{text}</div>' def _apply_formatting(self, text: str, formatting: list[FormatSpan]) -> str: """Apply formatting spans to text and escape HTML. Processes formatting spans (bold, italic, underline) and converts them to HTML tags while properly escaping the text content. Handles overlapping formatting spans correctly by processing them in reverse order. Args: text: The raw text content to format. formatting: List of FormatSpan objects indicating which parts of the text should have formatting applied (bold, italic, underline, or bold_italic combination). Returns: HTML-escaped text with formatting tags applied. Special characters are escaped to prevent XSS vulnerabilities. Example: >>> from fountain.elements import FormatSpan >>> renderer = HTMLRenderer() >>> text = "This is bold text" >>> formatting = [FormatSpan(start=8, end=12, format_type="bold")] >>> result = renderer._apply_formatting(text, formatting) >>> result == 'This is <strong>bold</strong> text' True """ open_tags: dict[FormatType, str] = { "bold": "<strong>", "italic": "<em>", "underline": "<u>", "bold_italic": "<strong><em>", } close_tags: dict[FormatType, str] = { "bold": "</strong>", "italic": "</em>", "underline": "</u>", "bold_italic": "</em></strong>", } return _wrap_spans(text, formatting, open_tags, close_tags, self._escape_html) def _render_dual_dialogue(self, element: FountainElement) -> str: """Render dual dialogue as side-by-side columns. Creates a two-column layout for simultaneous dialogue, with each character and their dialogue displayed side by side. This is used in screenplays when two characters speak at the same time. Args: element: A FountainElement of type DUAL_DIALOGUE containing metadata with left and right character/dialogue information. Returns: HTML string with dual-dialogue structure using CSS flexbox for side-by-side display. Returns empty string if metadata is missing. Note: The metadata should contain: - left_character: FountainElement for left character - left_dialogue: List of dialogue elements for left side - right_character: FountainElement for right character - right_dialogue: List of dialogue elements for right side """ if not element.metadata: return "" html_parts = ['<div class="fountain-dual-dialogue">'] for side, column in zip(("left", "right"), element.dual_dialogue_columns(), strict=True): html_parts.append(f'<div class="fountain-dual-dialogue-{side}">') html_parts.extend(self._render_element(column_element) for column_element in column) html_parts.append("</div>") html_parts.append("</div>") return "\n".join(html_parts) def _escape_html(self, text: str) -> str: """Escape HTML characters in text for safe display. Prevents XSS attacks and ensures special characters display correctly by converting HTML entities to their escaped equivalents. Args: text: Raw text that may contain HTML special characters. Returns: Text with HTML special characters escaped: - & becomes &amp; - < becomes &lt; - > becomes &gt; - " becomes &quot; - ' becomes &#x27; Example: >>> renderer = HTMLRenderer() >>> renderer._escape_html("<script>alert('XSS')</script>") '&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;' """ return html_module.escape(text, quote=True)
[docs] class FountainRenderer: """Renders FountainDocument back to Fountain markup format. The FountainRenderer provides round-trip conversion from parsed Fountain documents back to Fountain markup text. This is useful for programmatic manipulation of screenplays, formatting cleanup, or conversion workflows. Round-Trip Capabilities: - Preserves every element type and its order through parse -> render -> parse: scene headings, action, character cues, parentheticals, dialogue, transitions, dual dialogue, lyrics, sections, synopses, and notes all keep their types - Preserves the blank-line boundaries between structural blocks, so a character cue is not merged into the preceding action on re-parse (requirements A4, A4b, A4c) - Maintains title page metadata fields - Handles forced elements (scenes, action, transitions) - Preserves scene numbers and character extensions - Re-emits inline emphasis markers (``**``, ``*``, ``_``, ``***``) from the formatting spans, escaping literal ``*``, ``_``, and ``\\`` characters Round-Trip Limitations: - Exact whitespace formatting may differ from original - Comments in boneyard sections are maintained but may be reformatted - Line breaks within elements are preserved but spacing may normalize - Original capitalization in scene headings is preserved The renderer attempts to produce valid Fountain markup that will parse back to an equivalent document structure, though the exact text representation may differ from the original due to normalization during parsing. Example: Basic round-trip conversion: >>> from fountain.parser import FountainParser >>> from fountain.renderer import FountainRenderer >>> parser = FountainParser() >>> original = "INT. HOUSE - DAY\\n\\nJohn enters.\\n\\nJOHN\\nHello!" >>> doc = parser.parse(original) >>> renderer = FountainRenderer() >>> fountain_text = renderer.render(doc) >>> # Re-parse to verify round-trip >>> doc2 = parser.parse(fountain_text) >>> len(doc.elements) == len(doc2.elements) True """
[docs] def render(self, document: FountainDocument) -> str: """Render a FountainDocument as Fountain markup. Converts a parsed Fountain document back into Fountain markup text format. This enables round-trip conversion and programmatic screenplay manipulation. Args: document: The FountainDocument to render, containing parsed elements and optional title page metadata. Returns: A string containing valid Fountain markup that represents the document. The output can be saved as a .fountain file or parsed again. Example: >>> from fountain.parser import FountainParser >>> parser = FountainParser() >>> doc = parser.parse("Title: My Script\\n\\nFADE IN:") >>> renderer = FountainRenderer() >>> fountain = renderer.render(doc) >>> fountain.startswith("Title: My Script") True >>> "FADE IN:" in fountain True """ fountain_parts = [] # Render title page metadata if exists if document.metadata: fountain_parts.append(self._render_title_page(document.metadata)) # Render script body with structural blank-line separators body = self._render_body(document.elements) if body: fountain_parts.append(body) return "\n".join(fountain_parts)
# Element types that make up the interior of a dialogue block. When one of # these follows a dialogue-block predecessor it stays contiguous (single # newline) so re-parsing keeps the block together. _DIALOGUE_BODY_TYPES = frozenset({ElementType.DIALOGUE, ElementType.PARENTHETICAL, ElementType.LYRICS}) # Element types a dialogue-body line may attach to without a blank line: the # character cue that opens the block, or an earlier line of the same block. _DIALOGUE_PREDECESSOR_TYPES = frozenset( { ElementType.CHARACTER, ElementType.DIALOGUE, ElementType.PARENTHETICAL, ElementType.LYRICS, } ) def _render_body(self, elements: list[FountainElement]) -> str: """Render body elements with Fountain structural blank-line separators. Fountain treats a blank line as a structural boundary: scene headings, action paragraphs, character cues, and transitions each need a blank line before them so the parser does not merge them into the preceding element. The one exception is the interior of a dialogue block, where dialogue, parentheticals, and lyrics follow the character cue with no blank line. This joins each structural block with a blank line (``\\n\\n``) while keeping a character cue contiguous (single ``\\n``) with the dialogue, parentheticals, and lyrics that belong to it. Emitting the separators here is what lets ``parse(render(parse(text)))`` preserve element types instead of degrading CHARACTER and DIALOGUE to ACTION (requirement A4). Args: elements: The document body elements in order. Returns: The rendered body as a single string, or an empty string when no element produces output. """ rendered_parts: list[str] = [] previous_type: ElementType | None = None for element in elements: rendered = self._render_element(element) if not rendered: # Skip elements that produce no markup (e.g. DUAL_DIALOGUE), and do # not let them break the contiguity of a surrounding block. continue if previous_type is None: separator = "" elif self._continues_dialogue_block(element.type, previous_type): separator = "\n" else: separator = "\n\n" rendered_parts.append(separator + rendered) previous_type = element.type return "".join(rendered_parts) def _continues_dialogue_block(self, current_type: ElementType, previous_type: ElementType) -> bool: """Report whether ``current_type`` continues the previous dialogue block. A dialogue-body line (dialogue, parenthetical, or lyric) continues the current block, and so renders with no blank line before it, only when the previous rendered element was the character cue or another line of the same block. In every other case the element starts a new structural block. Args: current_type: The element type about to be rendered. previous_type: The type of the previous rendered element. Returns: True when the element should render contiguously with the previous one. """ return current_type in self._DIALOGUE_BODY_TYPES and previous_type in self._DIALOGUE_PREDECESSOR_TYPES def _render_title_page(self, metadata: dict[str, str]) -> str: """Render title page metadata as Fountain markup. Converts title page metadata back to Fountain format with proper field formatting. Fields are output in a logical order matching common screenplay conventions. Args: metadata: Dictionary of title page fields and their values. Keys should be lowercase (e.g., 'title', 'author'). Returns: Fountain-formatted title page with "Field: Value" format for each metadata field, followed by an empty line. Note: Field names are title-cased in output (e.g., 'draft date' becomes 'Draft Date'). The order follows screenplay conventions with title and author first, then other fields. """ title_parts = [] # Render known fields in the shared ordering for key, _css_class, _tag, _prefix, _multiline in TITLE_PAGE_FIELD_ORDER: if key in metadata: value = metadata[key] field_name = key.replace("_", " ").title() title_parts.append(f"{field_name}: {value}") # Render any custom/unknown metadata fields for key, value in metadata.items(): if key not in _KNOWN_TITLE_FIELDS: field_name = key.replace("_", " ").title() title_parts.append(f"{field_name}: {value}") # Add empty line after title page if title_parts: title_parts.append("") return "\n".join(title_parts) def _render_element(self, element: FountainElement) -> str: """Render a single FountainElement as Fountain markup. Converts a parsed element back to its Fountain text representation, including any special syntax markers (forced elements, scene numbers, character extensions, etc.). Args: element: The FountainElement to render with its type, text, and metadata. Returns: Fountain markup string for the element. A DUAL_DIALOGUE element is rendered as its paired left and right character blocks with the caret restored on the right cue (see ``_render_dual_dialogue``). Example: >>> from fountain.elements import FountainElement, ElementType >>> element = FountainElement( ... type=ElementType.SCENE_HEADING, ... text="INT. OFFICE - DAY", ... formatting=[], ... line_number=1, ... metadata={"scene_number": "42"} ... ) >>> renderer = FountainRenderer() >>> renderer._render_element(element) 'INT. OFFICE - DAY #42#' """ # Verbatim types (boneyard, note, page break) keep their raw text: the parser # never stripped emphasis from them, so re-emitting or escaping markers here would # corrupt content like the ``*`` in ``/* comment */``. if element.type in (ElementType.BONEYARD, ElementType.NOTE, ElementType.PAGE_BREAK): text = element.text else: text = self._restore_emphasis(element.text, element.formatting) forced = bool(element.metadata.get("forced")) match element.type: case ElementType.SCENE_HEADING: scene_text = f".{text}" if forced else text if "scene_number" in element.metadata: scene_text += f" #{element.metadata['scene_number']}#" return scene_text case ElementType.ACTION: return f"!{text}" if forced else text case ElementType.CHARACTER: char_text = text if "extension" in element.metadata: char_text += f" ({element.metadata['extension']})" elif element.metadata.get("continuation"): char_text += " (CONT'D)" if forced: char_text = f"@{char_text}" if element.metadata.get("dual_dialogue"): char_text += "^" return char_text case ElementType.TRANSITION: return f">{text}" if forced else text case ElementType.SECTION: level = element.metadata.get("level", 1) return f"{'#' * (level if isinstance(level, int) else 1)} {text}" case ElementType.SYNOPSIS: return f"= {text}" case ElementType.DUAL_DIALOGUE: return self._render_dual_dialogue(element) case ElementType.PAGE_BREAK: return "===" case ElementType.CENTERED: return f">{text}<" case ElementType.LYRICS: # The Fountain lyric marker is a leading ``~`` only; the parser strips # just that leading tilde. Emitting a trailing ``~`` would accrete a # literal tilde into the text on every round trip (requirement A4c). return f"~{text}" case _: # Dialogue, parenthetical, note, and boneyard need no markers. return text def _render_dual_dialogue(self, element: FountainElement) -> str: """Render a DUAL_DIALOGUE element back to Fountain markup. Emits the left character block followed by a blank line and the right character block, with the caret (``^``) restored on the right cue. The parser detects dual dialogue by that caret on the second character, so re-parsing the output reproduces a DUAL_DIALOGUE element (requirement A4b). The caret itself is emitted by ``_render_element`` because the right character carries ``dual_dialogue`` metadata; this method only arranges the two columns and their blank-line separation. Each column is rendered through ``_render_body`` so the character cue stays contiguous with its dialogue and parentheticals (single newline), matching the single-column A4 round-trip behavior. Args: element: A DUAL_DIALOGUE element whose metadata carries ``left_character``, ``left_dialogue``, ``right_character``, and ``right_dialogue``. Returns: Fountain markup for the paired dual dialogue block, or an empty string when the dual dialogue metadata is missing. """ if not element.metadata: return "" left_column, right_column = element.dual_dialogue_columns() return f"{self._render_body(left_column)}\n\n{self._render_body(right_column)}" def _restore_emphasis(self, text: str, formatting: list[FormatSpan]) -> str: """Re-emit Fountain emphasis markers around the formatted spans. The parser strips emphasis delimiters and records content-only spans, so this reinserts the matching markers (``**``, ``*``, ``_``, ``***``) at each span's boundaries with the same nesting sweep the HTML renderer uses, so the result is well-formed. Literal ``*``, ``_`` and ``\\`` characters in the clean text are backslash-escaped so they are not re-read as emphasis, keeping ``parse -> render -> parse`` stable. Args: text: The element's clean text (emphasis delimiters already stripped). formatting: Content-only spans indexing into ``text``. Returns: Fountain markup with the emphasis delimiters restored. """ markers: dict[FormatType, str] = {"bold": "**", "italic": "*", "underline": "_", "bold_italic": "***"} return _wrap_spans( text, formatting, markers, markers, lambda char: f"\\{char}" if char in ("*", "_", "\\") else char, )