# 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", " ")
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 &
- < becomes <
- > becomes >
- " becomes "
- ' becomes '
Example:
>>> renderer = HTMLRenderer()
>>> renderer._escape_html("<script>alert('XSS')</script>")
'<script>alert('XSS')</script>'
"""
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,
)