A Zig library for parsing and writing Fountain (https://fountain.io) screenplay documents.
  • Zig 99%
  • Shell 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 77c4f268c6
All checks were successful
test / test (push) Successful in 33s
zig fmt (0.17)
Builtin renames, struct literals and string tables reflowed. No behavior
change; 171/171 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 12:52:48 +02:00
.forgejo/workflows Migrate to Zig 0.17 2026-10-06 12:52:39 +02:00
bench Migrate to Zig 0.17 2026-10-06 12:52:39 +02:00
docs Initial release: Fountain v1.1 parser and writer 2026-03-24 11:45:28 +01:00
scripts Migrate to Zig 0.17 2026-10-06 12:52:39 +02:00
src zig fmt (0.17) 2026-10-06 12:52:48 +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 Migrate to Zig 0.17 2026-10-06 12:52:39 +02:00
build.zig.zon Migrate to Zig 0.17 2026-10-06 12:52:39 +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.17 2026-10-06 12:52:39 +02:00
DESIGN.md Migrate to Zig 0.17 2026-10-06 12:52:39 +02:00
LICENSE Add README and MIT LICENSE 2026-06-06 20:07:49 +02:00
README.md Migrate to Zig 0.17 2026-10-06 12:52:39 +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.17.0 or newer.
  • 146 tests; ~120–220 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 (-Doptimize=fast)

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