A Zig library for Final Draft fdx screenplay interchange.
|
Mikael Säker
5fdf01f107
Document owns an internal arena (parse: thousands of allocs → a handful)
fdx.parse built the Document with a per-field allocation for every element's fd_type/text/styles and each ArrayList growth — thousands of small allocations, which the Debug allocator's per-allocation tracking made very slow (a feature-length script took ~390ms just to parse in a debug build; ~4ms in release). Document now owns an internal ArenaAllocator: parse allocates all contents from it (a handful of arena chunks instead of thousands of backing calls) and deinit is a single arena free. `arena` is optional — null for the borrowed views a caller assembles to hand to `write` (which only reads elements/title), so `deinit` is a safe no-op there. Tests + gated fuzz (leak check) pass; opening Big Fish in reveal's debug build dropped from ~2.30s to ~1.70s (the fdx-open overhead over baseline: ~1.15s → ~0.15s). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| src | Document owns an internal arena (parse: thousands of allocs → a handful) | |
| tests | Initial commit: zfdx — Final Draft interchange for Zig | |
| .gitignore | Initial commit: zfdx — Final Draft interchange for Zig | |
| build.zig | Gate fuzz harnesses behind -Dfuzz (skip by default) | |
| build.zig.zon | Initial commit: zfdx — Final Draft interchange for Zig | |
| CLAUDE.md | Initial commit: zfdx — Final Draft interchange for Zig | |
| LICENSE | Initial commit: zfdx — Final Draft interchange for Zig | |
| README.md | Preserve all run styles; capture the title page as raw paragraphs | |
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
constzfdx=@import("zfdx");// Final Draft XML filesvardoc=tryzfdx.parse(allocator,fdx_bytes);// .fdx → Documentdeferdoc.deinit();for(doc.elements)|el|{// el.fd_type, el.text, el.styles}constout=tryzfdx.write(allocator,doc);// Document → .fdxdeferallocator.free(out);// macOS clipboard RTFconstels=tryzfdx.rtf.parse(allocator,rtf_bytes,null);// RTF → []Elementconstrtf=tryzfdx.rtf.emit(allocator,els);// []Element → RTFData model (zfdx.types)
Element=struct{fd_type:[]constu8,text:[]constu8,styles:[]constStyleSpan,dual:Dual}StyleSpan=struct{start:usize,end:usize,style:Style}// byte offsetsStyle=struct{bold,italic,underline,all_caps,strikeout,superscript,subscript,hidden:bool}Dual=enum{none,left,right}// dual-dialogue columnTitlePara=struct{alignment:Alignment,text:[]constu8,styles:[]constStyleSpan}Alignment=enum{left,center,right,full}Document=struct{elements:[]Element,title:[]TitlePara,...}// owns its contentsScope
- Run styles: the full Final Draft set is preserved — Bold, Italic, Underline, AllCaps, Strikeout, SuperScript, SubScript, HiddenText. Consumers that only handle some (e.g. a Fountain writer → Bold/Italic/Underline) just ignore the rest; nothing is dropped at the library layer.
- 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. - Title page: captured as raw, positioned paragraphs
(
Document.title: []TitleParaof{ alignment, text, styles }, 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.
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/ and are round-tripped as part of the
suite.
License
MIT — see LICENSE.