XFA (XML Forms Architecture) is a 756-page specification layered on top of PDF. It defines an XML-based form engine with its own layout model, scripting language, data binding, and pagination rules. Adobe built it in the late 1990s, shipped it in Acrobat 6, and deprecated it in PDF 2.0. The deprecation didn't make the existing forms go away.
Implementing XFA from scratch — in any language — is a multi-year project. In Rust, the memory safety guarantees helped a lot. The borrow checker prevented an entire class of bugs that would have been silent data corruption in C++. What follows is a technical account of how we built it, what was hard, and where we made tradeoffs.
The four layers of XFA
XFA inside a PDF is a stream of XML subtrees embedded in the document's /XFA key. At minimum, a dynamic XFA document contains:
- —Template — describes form fields, their layout, scripting, and data bindings
- —Datasets — the actual data values that fields are populated with
- —Config — rendering configuration (page layout, units, locale)
- —LocaleSet — locale-specific formatting for dates, numbers, currency
Rendering an XFA form means: parse the template, resolve data bindings from datasets, compute the layout (handling field expansion and pagination), then rasterize the result. Our stack maps cleanly to four crates:
- —
xfa-dom-resolver— parses all XFA XML streams, resolves SOM paths - —
formcalc-interpreter— lexer, parser, and interpreter for FormCalc - —
xfa-layout-engine— box model, dynamic field expansion, pagination - —
pdf-xfa— integrates withpdf-engine, writes flattened output
SOM path resolution
SOM (Script Object Model) paths are XFA's way of referencing form elements from scripts. A path like xfa.form.Page1.Address.StreetLine1.value navigates a tree that spans both the template DOM and the datasets DOM simultaneously.
The tricky part: XFA path resolution is context-sensitive. The same path segment can mean different things depending on whether you're resolving from within a template subform, a dataset node, or a script expression. The spec defines four resolution modes — simple, strict, unstrict, and any — and the correct mode depends on where the path appears in the document.
Our implementation uses a two-phase approach: first build a merged DOM from the template and datasets trees, then resolve paths against that combined tree using a recursive descent resolver that carries context through each step. The Rust type system helped here — each resolution mode is a distinct enum variant, and the compiler ensures we handle all four in every match expression.
pub enum SomResolutionMode {
Simple, // field.value
Strict, // $.field.value — must match exactly
Unstrict, // *.field.value — ancestor search
Any, // field — search from current context up
}
pub fn resolve_path(
path: &SomPath,
context: &XfaNode,
mode: SomResolutionMode,
) -> Result<Option<XfaNode>, XfaError> {
match mode {
SomResolutionMode::Simple => resolve_simple(path, context),
SomResolutionMode::Strict => resolve_strict(path, context),
SomResolutionMode::Unstrict => resolve_ancestor_search(path, context),
SomResolutionMode::Any => resolve_any(path, context),
}
}FormCalc: a language within a language
FormCalc is XFA's scripting language. It's a stateful, weakly-typed language with 120+ built-in functions, implicit type coercion, null propagation, and non-obvious scoping rules. Fields can compute their values via FormCalc expressions; scripts can run on events (click, change, enter, exit, initialize, calculate, validate).
We wrote the interpreter in pure Rust with three stages: lexer → parser → evaluator. The grammar is straightforward but the semantics aren't — particularly around null handling. In FormCalc, Null + 1 = Null, but Null & "text" = "text". The if statement is an expression that returns a value, not a statement. And some functions have different behavior depending on whether they're called in a template context vs. a script context.
The calculate event chain
When a field value changes, XFA fires a chain of events: change → calculate → validate → exit. Each event can trigger scripts on other fields, which can trigger further events. Cycles are possible and must be detected.
We handle this with a topological sort of the dependency graph before rendering. Fields declare their data bindings and script dependencies at parse time; we build a directed graph and detect cycles before executing any scripts. If a cycle exists, we flag it as an error rather than running until the call stack overflows.
// Detect cycles in the FormCalc dependency graph
pub fn validate_dependency_graph(
fields: &[FieldNode],
) -> Result<Vec<FieldId>, XfaError> {
let mut graph = DependencyGraph::new();
for field in fields {
for dep in field.script_dependencies() {
graph.add_edge(field.id, dep);
}
}
// Returns topological order or XfaError::CyclicDependency
graph.topo_sort()
}Dynamic reflow
Static XFA forms have fixed layouts — each field is at a fixed position on a fixed-size page. Dynamic XFA forms allow fields to expand vertically as their content grows, and the rest of the form reflows around them. Subforms can span multiple pages; page breaks can be explicit or automatic.
This is where XFA gets genuinely difficult. The layout algorithm is recursive: a subform's height depends on the heights of its children, which depend on their content and their own children. Fields with expand="1" grow to fit text content. Subforms with layout="tb" (top-to-bottom) stack their children vertically. Pagination splits content across pages when it overflows.
Our layout engine works in two passes. The first pass (measure) computes intrinsic sizes bottom-up. The second pass (arrange) places elements top-down, breaking to new pages when necessary and re-measuring reflowed content. We use arena allocation for the layout tree to avoid repeated heap allocations during the measure pass.
Benchmarks
We measured XFA processing time against two reference implementations: Adobe Acrobat (via automation) and Foxit PDF SDK. All tests on AWS c6i.2xlarge, 100 runs, median result.
| Test | PDFluent | Foxit SDK | Acrobat (via COM) |
|---|---|---|---|
| XFA flatten (static, 10 pages) | 85ms | ~180ms | ~420ms |
| XFA flatten (dynamic, 50 fields) | 210ms | ~390ms | ~850ms |
| FormCalc execution (100 expressions) | 12ms | ~28ms | ~65ms |
| XFA data extraction | 45ms | ~95ms | ~180ms |
| Cold start + flatten | 95ms | ~580ms | ~2,100ms |
The cold start difference (95ms vs 580ms) is almost entirely Rust vs. shared library load time. On repeated calls — a batch processing scenario — the per-document gap narrows to roughly 2×.
We're faster. We're also less battle-tested. Foxit's XFA engine has processed documents that we've probably never seen. There are form designs that exercise spec sections we haven't fully implemented. We track these in our test corpus and fix them, but the honest position is that Foxit has a 15-year lead on edge cases.
What doesn't work yet
Our internal XFA corpus is 1,150 enterprise documents (xfa-forms, xfa-golden, and xfa-extra subsets). Structural flatten passes the crash-safety and 30-second-per-document timeout gates across the full corpus. Visual fidelity versus reference output is not yet a published claim — font metrics and complex layouts remain the active improvement area. The known parse gaps:
- —A form with page ordering that differs from our rendering order — a spec ambiguity we haven't resolved
- —A font metric mismatch (0.0183 below our SSIM threshold) — line spacing calculation differs by a fraction
- —A form that uses Helvetica in the XFA spec but renders with Arial on Windows; SSIM comparison shows wrong because the reference renderer made a different font substitution
FormCalc coverage is 96% of the 120+ built-in functions. The missing 4% are mostly locale-specific financial functions (Apr(), Ipmt(), Fv()) that appear in very few real-world forms.
The spec vs. reality
"The XFA 3.3 specification is the normative reference. Adobe Acrobat behavior is the actual reference."
We learned this the hard way. The spec says one thing; Acrobat does another; real-world forms are authored to match Acrobat. In many cases we implement both the spec behavior and the Acrobat-compatible behavior, switching based on a compatibility flag.
The para hAlign attribute is a good example. The spec says alignment applies to the text run inside the field. Acrobat applies it to the field's bounding box. Forms authored with Acrobat produce different output if you follow the spec strictly. We match Acrobat.
What's next
The remaining known failures are tracked in our public issue list. The FormCalc coverage gaps are next on the roadmap. After that: XFA accessibility (mapping XFA roles to PDF tags for PDF/UA compliance) and better support for XFA forms that mix AcroForm fields with XFA content — a pattern that appears in some government forms.
The code is proprietary but the test results are public. If you have an XFA form that doesn't process correctly, open an issue with the file (or a minimal reproduction) and we'll look at it.