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 a large set of 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.
// Illustration of the approach, not a published API: DependencyGraph
// is internal to the layout engine and is not part of the SDK surface.
// 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've removed the previous timing comparison as we couldn't reproduce the test run. Until we can publish a proper benchmark with a documented corpus and test harness, we have no reliable performance data to share. The only honest position is that no valid comparison exists at this time.
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
We support most FormCalc built-in functions. The few that are missing are mainly locale-specific financial functions like Apr(), Ipmt() and Fv(), which rarely appear in actual 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.
Frequently asked questions
- What is XFA in PDF?
- XFA (XML Forms Architecture) is an XML-based form engine built into PDF. It includes its own layout model, scripting language, and data binding rules. Adobe introduced it in Acrobat 6 but deprecated it in PDF 2.0. XFA forms contain XML streams for templates, datasets, configuration, and locale settings.
- How does XFA handle form scripting?
- XFA uses FormCalc, a weakly-typed scripting language with a large set of built-in functions. It handles events like clicks, changes, and validation. FormCalc has unique behaviors like null propagation (Null + 1 = Null) and context-dependent function behavior. Scripts can trigger chains of calculate, validate, and exit events across fields.
- What makes XFA layout difficult?
- Dynamic XFA forms require complex reflow when fields expand. The layout algorithm must recursively calculate sizes, handle page breaks, and manage footers across pages. Nested expandable elements like tables inside subforms are particularly challenging, requiring multiple measurement passes to account for variable content.
- How does Rust help with XFA implementation?
- Rust's memory safety prevents silent data corruption bugs common in C++. Its type system enforces proper handling of XFA's four path resolution modes. Arena allocation in the layout engine reduces heap allocations during measurement passes. The borrow checker also helps manage complex DOM manipulations safely.