A Zig library for parsing and writing Fountain (https://fountain.io) screenplay documents.
  • Zig 99.2%
  • Shell 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker d41173342d parser: notes belong to every element, not just the styled ones
A `[[note]]` written into a scene heading, a transition or a
parenthetical stayed in the element's text and PRINTED — the one thing a
note must never do. Those three parsers keep their line verbatim, so
`inline.resolve`, which is what lifts notes out, never ran on them.
SceneHeading even carried an unused `notes` field.

`inline.extractHidden` is `resolve` without the emphasis pass: same first
pass, so offsets, escapes and boneyard markers behave as they do
everywhere else, but `*` and `_` stay in the text as characters — these
elements hold a plain string with no span list to put emphasis in.

The parser's `Hidden` helper carries the result through the slicing each
of those parsers does: `rebase` moves offsets into the trimmed text and
`own` keeps the surviving slice independent of the lift buffer. Nothing
is allocated when a line has no hidden text, which is nearly every line.
A payload never disappears — a note past a trim (or inside a scene
number) clamps to the nearest edge, because the text it annotated may be
gone but the writer's words are not ours to delete. The one visible
consequence: a note written after trailing space in a heading loses that
space on the first save, and is byte-stable from then on.

The writer sends those elements through writeStyledText with no spans, so
the notes go back where they were and the [[ and /* escaping applies to
them too — which it now can, since extractHidden undoes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:31:15 +02:00
bench Migrate to Zig 0.16, add inline note positions and zero-diff round-trips 2026-06-06 20:00:04 +02:00
docs Initial release: Fountain v1.1 parser and writer 2026-03-24 11:45:28 +01:00
scripts setup-tools: detect checkouts with rev-parse, not [ -d .git ] 2026-07-25 23:33:07 +02:00
src parser: notes belong to every element, not just the styled ones 2026-08-09 08:31:15 +02:00
.gitignore Initial release: Fountain v1.1 parser and writer 2026-03-24 11:45:28 +01:00
BENCHMARKS.md Initial release: Fountain v1.1 parser and writer 2026-03-24 11:45:28 +01:00
build.zig Initial release: Fountain v1.1 parser and writer 2026-03-24 11:45:28 +01:00
build.zig.zon Migrate to Zig 0.16, add inline note positions and zero-diff round-trips 2026-06-06 20:00:04 +02:00
CLAUDE.md docs: repoint links to git.urverk.org 2026-07-25 11:41:40 +02:00
context.md Migrate to Zig 0.16, add inline note positions and zero-diff round-trips 2026-06-06 20:00:04 +02:00
DESIGN.md Migrate to Zig 0.16, add inline note positions and zero-diff round-trips 2026-06-06 20:00:04 +02:00
LICENSE Add README and MIT LICENSE 2026-06-06 20:07:49 +02:00
README.md docs: repoint links to git.urverk.org 2026-07-25 11:41:40 +02:00

zfountain

A Zig library for parsing and writing Fountain screenplay documents.

Fountain is a plain-text markup format for screenplays — "make it look like a screenplay and it is one." zfountain turns Fountain text into a simple, typed AST and writes that AST back out as valid Fountain, with a focus on faithful round-trips.

var doc = try zfountain.parse(allocator, source);
defer doc.deinit();

const text = try zfountain.write(allocator, doc); // back to Fountain
defer allocator.free(text);

Why

  • Standalone — no dependencies; usable from any project.
  • Round-trip faithful — parse → write reproduces real documents byte-for-byte (verified against the Brick & Steel reference screenplay and a fixture covering every element type).
  • Inline styles resolved*italics*, **bold**, _underline_ etc. are parsed into style spans over clean text, not left as raw markup.
  • Integration-friendly — the AST is small enough to map onto any document model.

Status

  • Implements the full Fountain v1.1 syntax.
  • Requires Zig 0.16.0 or newer.
  • 146 tests; ~120220 MB/s parse throughput (see BENCHMARKS.md).

Features

Elements

  • Scene headings — INT/EXT/EST/I/E (dot or space), forced (.), and scene numbers (#1A#)
  • Action — including forced (!), centered (> ... <), tab→4-space expansion, and preserved indentation
  • Characters — extensions ((O.S.)), dual dialogue (^), forced (@), mixed-case names
  • Dialogue & parentheticals, with multi-line and two-space continuation rules
  • Transitions — automatic (... TO:) and forced (>)
  • Lyrics (~), sections (#######), synopses (=), page breaks (===)

Inline

  • Emphasis with Markdown-style flanking rules: italic, bold, bold-italic, underline, and combinations — resolved into non-overlapping style spans.
  • Escapes (\*, \_, \[, \]).
  • Notes [[ ... ]] — both standalone (their own element) and inline. Inline notes remember their position in the text, so a mid-line note round-trips exactly where it was rather than being shoved to the end.
  • Boneyard /* ... */ — stripped during parsing (not kept in the AST).

Install

Fetch it into your build.zig.zon:

zig fetch --save git+https://git.urverk.org/urverk/zfountain.git

Then wire it into your build.zig:

const zfountain = b.dependency("zfountain", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("zfountain", zfountain.module("zfountain"));

And import it:

const zfountain = @import("zfountain");

Usage

Parse, inspect, write

const std = @import("std");
const zfountain = @import("zfountain");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    const source =
        \\Title: Big Fish
        \\Author: John August
        \\
        \\INT. HOUSE - DAY
        \\
        \\EDWARD pours a drink. _Slowly_.
        \\
        \\EDWARD
        \\(quietly)
        \\Here's how it happened.
        \\
    ;

    var doc = try zfountain.parse(alloc, source);
    defer doc.deinit();

    // Title page is a list of key/value entries.
    for (doc.title_page) |entry| {
        std.debug.print("{s} = {s}\n", .{ entry.key, entry.value });
    }

    // Body is a flat list of tagged elements.
    for (doc.elements) |elem| switch (elem) {
        .scene_heading => |h| std.debug.print("SCENE: {s}\n", .{h.text}),
        .character => |c| std.debug.print("CHAR: {s}\n", .{c.name}),
        .dialogue => |d| std.debug.print("LINE: {s}\n", .{d.text.plain}),
        else => {},
    };

    // Write it back to Fountain text.
    const out = try zfountain.write(alloc, doc);
    defer alloc.free(out);
    std.debug.print("\n{s}", .{out});
}

parse borrows slices from the source where it can, so keep source alive for the lifetime of the Document (or until you've finished with it). doc.deinit() frees everything the document owns.

Working with styled text

Text-bearing elements (action, dialogue, lyrics) carry a StyledText: the markup-stripped plain string plus a sorted, non-overlapping list of spans:

// "EDWARD pours a drink. *Slowly*." ->
//   plain: "EDWARD pours a drink. Slowly."
//   spans: [{ start: 22, end: 28, style: .italic }]
for (action.text.spans) |span| {
    const word = action.text.plain[span.start..span.end];
    std.debug.print("{s} is {s}\n", .{ word, @tagName(span.style) });
}

Inline notes attached to an element expose their text and the byte offset into plain where they appeared.

The document model

Document
├── title_page : []TitleEntry        // { key, value }
└── elements   : []Element           // tagged union

Element = scene_heading | action | character | dialogue | parenthetical
        | transition | lyrics | section | synopsis | note_block
        | page_break | blank_line

StyledText = { plain: []u8, spans: []Span }
Span       = { start, end, style }
Style      = bold | italic | bold_italic | underline
           | underline_bold | underline_italic | underline_bold_italic
Note       = { text, offset }        // offset into the element's plain text

See DESIGN.md for the full model and parsing/writing notes.

Build & test

zig build test     # run the test suite
zig build bench    # run performance benchmarks (ReleaseFast)

Round-trip guarantee

Two byte-for-byte zero-diff tests assert write(parse(x)) == x:

  • the real Brick & Steel reference screenplay (round-trips as-is), and
  • a kitchen-sink fixture covering every element type and variant.

A third test deep-compares every element field (including style spans and note offsets) across a parse → write → parse cycle.

Project layout

src/
├── zfountain.zig   — public API (parse, write, types)
├── types.zig       — Document, Element, StyledText, Note, …
├── parser.zig      — Fountain → Document
├── inline.zig      — emphasis/note resolver
├── writer.zig      — Document → Fountain
├── *_test.zig      — tests
└── testdata/       — round-trip fixtures
docs/               — Fountain v1.1 spec + reference screenplay
bench/              — benchmarks

License

MIT © Mikael Säker