A Zig library for Final Draft fdx screenplay interchange.
  • Zig 99%
  • Shell 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 82e4109c4d
All checks were successful
test / test (push) Successful in 25s
xml: normalise line ends on parse (XML 1.0 §2.11)
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>
2026-10-19 17:08:49 +02:00
.forgejo/workflows Migrate to Zig 0.17 2026-10-06 11:55:04 +02:00
scripts Migrate to Zig 0.17 2026-10-06 11:55:04 +02:00
src xml: normalise line ends on parse (XML 1.0 §2.11) 2026-10-19 17:08:49 +02:00
tests Initial commit: zfdx — Final Draft interchange for Zig 2026-06-30 10:28:37 +02:00
.gitignore scripts: setup-tools, the one every repo shares 2026-09-22 21:06:15 +02:00
build.zig Gate fuzz harnesses behind -Dfuzz (skip by default) 2026-07-01 02:52:01 +02:00
build.zig.zon Migrate to Zig 0.17 2026-10-06 11:55:04 +02:00
CLAUDE.md docs: repoint links to git.urverk.org 2026-07-25 11:43:43 +02:00
context.md xml: normalise line ends on parse (XML 1.0 §2.11) 2026-10-19 17:08:49 +02:00
DESIGN.md xml: normalise line ends on parse (XML 1.0 §2.11) 2026-10-19 17:08:49 +02:00
LICENSE Initial commit: zfdx — Final Draft interchange for Zig 2026-06-30 10:28:37 +02:00
README.md Round-trip files from other FDX writers (courier's fdx-corpus) 2026-10-16 09:17:29 +02:00

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 FDElementName markers).

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 one Run, 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 — including Style="" vs. no Style. 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, and types.runsFromSpans(alloc, text_len, spans) / Run.styled(start, end, style) build runs back (no attribute allocation).

  • Paragraphs: props.attrs keeps every <Paragraph> attribute as written (except Type, which is fd_type; the writer re-inserts it at its sorted position, as FD writes them), and props.extra keeps 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 shared DualBlock (wrapper attributes, block boundaries). Caller-built documents may leave block null; dual alone then groups them.

  • Title page: captured as raw, positioned paragraphs (Document.title: []TitlePara of { 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's Title: / 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"). The Content and TitlePage/Content entries are empty placeholders marking where write puts 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 ordered pieces() — .text runs and .label DynamicLabels such as "Page #" — and tabstops()), 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's paragraphs()), older in-paragraph notes via el.scriptNotes() (with byte offsets), and el.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 the settings.ParaView view (props(), pieces(), tabstops(), text(alloc)).

  • Note positions (zfdx.anchor): toRange(els, start, end) and fromRange(els, position) convert between a note's Range/Subrange and (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), change and remove, 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 write is canonical, i.e. write(parse(write(D))) == write(D) byte-for-byte;
  • robustness — parse is 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.