- Zig 99.2%
- Shell 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| bench | ||
| docs | ||
| scripts | ||
| src | ||
| .gitignore | ||
| BENCHMARKS.md | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| LICENSE | ||
| README.md | ||
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; ~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 (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