- Zig 99%
- Shell 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 33s
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> |
||
| .forgejo/workflows | ||
| 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.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