- Zig 99%
- Shell 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
test / test (push) Successful in 25s
CRLF and lone CR become LF in literal text, CDATA and attribute values; / 
 references still give CR. A CRLF-saved FDX no longer puts "\r" into Element.text. Writing stays LF. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| LICENSE | ||
| README.md | ||
zfdx
A Zig library for Final Draft screenplay interchange, built around one small, neutral data model.
Final Draft scripts travel in two shapes that share the same element vocabulary:
.fdx— Final Draft's XML file format.- clipboard RTF — what Final Draft puts on the macOS pasteboard (RTF carrying
FDElementNamemarkers).
zfdx parses both into a typed list of screenplay Elements — each a Final Draft
type name ("Scene Heading", "Action", "Dialogue", …), its text, and inline
emphasis spans — and writes them back out, with a focus on faithful, stable
round-trips. It is intentionally model-agnostic: it knows nothing about any
app's document model, so each consumer maps Element ↔ its own element kinds.
Depends only on std.
Usage
const zfdx = @import("zfdx");
// Final Draft XML files
var doc = try zfdx.parse(allocator, fdx_bytes); // .fdx → Document
defer doc.deinit();
for (doc.elements) |el| {
// el.fd_type, el.text, el.runs (r.style(), r.font(), r.size(), r.color(), …)
}
const out = try zfdx.write(allocator, doc); // Document → .fdx
defer allocator.free(out);
// macOS clipboard RTF
const els = try zfdx.rtf.parse(allocator, rtf_bytes, null); // RTF → []Element
const rtf = try zfdx.rtf.emit(allocator, els); // []Element → RTF
Data model (zfdx.types)
Element = struct { fd_type: []const u8, text: []const u8, runs: []const Run, props: Props,
dual: Dual, block: ?*const DualBlock }
Props = struct { attrs: []const xml.Attr, extra: []const xml.Node, extra_at: []const u32 } // paragraph attrs + non-text children (+ the run each precedes)
Run = struct { start: usize, end: usize, attrs: []const xml.Attr } // one <Text>; byte range
StyleSpan = struct { start: usize, end: usize, style: Style } // derived view: styleSpans(runs)
Style = struct { bold, italic, underline, all_caps, strikeout, superscript, subscript, hidden: bool }
Dual = enum { none, left, right } // dual-dialogue column
DualBlock = struct { attrs, extra, dd_attrs, dd_extra } // the <DualDialogue> wrapper, shared per block
TitlePara = struct { text: []const u8, runs: []const Run, props: Props }
Alignment = enum { left, center, right, full }
Document = struct { elements: []Element, title: []TitlePara,
root_attrs: []xml.Attr, blocks: []xml.Node, … } // owns its contents
Scope
-
Runs: each
<Text>is oneRun, a byte range of the element's text plus its attributes exactly as written (Font, Size, Color, Background, Style, AdornmentStyle, RevisionID, …), so a round-trip is exact — includingStyle=""vs. noStyle. Accessors decode them:style()(the full FD set: Bold, Italic, Underline, AllCaps, Strikeout, SuperScript, SubScript, HiddenText),font(),size(),color()/background()(Rgb16),revisionId(),adornmentStyle(). For emphasis-only consumers,types.styleSpans(alloc, runs)gives merged spans, andtypes.runsFromSpans(alloc, text_len, spans)/Run.styled(start, end, style)build runs back (no attribute allocation). -
Paragraphs:
props.attrskeeps every<Paragraph>attribute as written (exceptType, which isfd_type; the writer re-inserts it at its sorted position, as FD writes them), andprops.extrakeeps non-text children such as a Scene Heading's<SceneProperties>, each at its place among the runs (props.extra_at,props.offsetOf()). Accessors (null = absent, i.e. inherit from ElementSettings):alignment(),leftIndent(),rightIndent(),firstIndent(),spaceBefore(),spacing(),leading(),startsNewPage(),number()(scene number),id(),color(),background(),sceneProperties(). Self-closing paragraphs are kept as empty elements (FD lays them out as blank lines). -
Dual dialogue (
<DualDialogue>) is preserved: the elements stay flat, but each is tagged with its column (Element.dual=.left/.right), which maps to Fountain's^on the right speaker's cue, and points at the block's sharedDualBlock(wrapper attributes, block boundaries). Caller-built documents may leaveblocknull;dualalone then groups them. -
Title page: captured as raw, positioned paragraphs (
Document.title: []TitleParaof{ text, runs, props }, spacers included). FD's title page is free-form layout with no field labels, so any key/value interpretation (e.g. Fountain'sTitle:/Author:) is left to the consumer. -
Everything else (PageLayout, ElementSettings, HeaderAndFooter, Revisions, Watermarking, …): every top-level block is kept in file order as a generic XML tree (
Document.blocks,zfdx.xml.Node= name, attributes in source order, content) and written back unchanged, so blocks zfdx doesn't model yet survive a round-trip and can be read directly:doc.block("PageLayout").?.attr("TopMargin"),doc.blocksNamed("ElementSettings"). TheContentandTitlePage/Contententries are empty placeholders marking wherewriteputs the body and title. -
Settings views (
zfdx.settings): typed, read-only views over the blocks, decoding on demand (nothing copied; the tree still round-trips).doc.pageLayout()(page size in inches, margins in points),doc.elementSettings("Action")/doc.allElementSettings()(font()→TextFormat,paragraph()→Props,paginateAs(),returnKey(), …),doc.headerAndFooter()/doc.titleHeaderAndFooter()(visibility,startingPage(),header()/footer()paragraphs with orderedpieces()—.textruns and.labelDynamicLabels such as "Page #" — andtabstops()),doc.moresAndContinueds()((MORE)/(CONT'D)/CONTINUED texts and flags),doc.sceneNumberOptions(). Revisions, LockedPages, Watermarking etc. are read from the generic tree. -
Notes & scene metadata (
zfdx.notes, for FDX → Fountain):doc.scriptNotes()(name, kind, color, dates,range()/subrange()/position(), the note'sparagraphs()), older in-paragraph notes viael.scriptNotes()(with byte offsets), andel.sceneProperties()on a Scene Heading (title(),length(),page(),attributedTitle(),arcBeats()per character). Summary paragraphs are ordinary body elements (fd_type == "Summary"). Header/footer, note and beat paragraphs share thesettings.ParaViewview (props(),pieces(),tabstops(),text(alloc)). -
Note positions (
zfdx.anchor):toRange(els, start, end)andfromRange(els, position)convert between a note'sRange/Subrangeand(element index, byte offset)anchors, by FD's UTF-16 counting. -
Note editing (
zfdx.note_edit):add(next free Id, fresh RefId, FD's body format),changeandremove, for top-level notes (by Id) and older in-paragraph ones (by element + order). Untouched attributes and body paragraphs stay verbatim.
Testing
zig build test runs the unit suite plus two seeded fuzz harnesses:
- structural — random documents check that
writeis canonical, i.e.write(parse(write(D))) == write(D)byte-for-byte; - robustness —
parseis required to be total on arbitrary / truncated / corrupted input (no crash, loop, or leak).
Real Final Draft files live under tests/; every .fdx there — and, when the
sibling checkout exists, in ../courier/test/ref/fd13/gen/ (Final Draft
13-saved files) — is read in place at test time and must survive
write(parse(x)) with an identical XML tree. So must the other tools' files in
../courier/fdx-corpus/fdx/ (except whitespace-only paragraph content,
written back self-closing).
License
MIT — see LICENSE.