Scripts got an object that takes anything for `this.border`, `this.ui`,
`this.validate` and the like (XFA 3.3 ch. 3, "Properties vs. Children",
p. 78), so `ui.oneOfChild.className == "choiceList"` was never true,
`this.validate.nullTest == "error"` never matched, and
`border.getElement("edge", 0).color.value` threw a TypeError.
The parser now keeps each object's own attributes and its property
elements (border, font, para, margin, ui, validate, occur, break,
breakBefore, breakAfter, assist, format, value, bind, desc, extras, keep,
caption, items, calculate) as a generic tree, bounded per object: 256
elements, 16 KiB of text, 8 levels, 64 attributes an element; past that
the rest is left out with one warning. An element keeps only its own
text, not that of children left out (an extras, a script); a validation
or a calculation keeps its first script with its text outside the
budget, as forms read it, within the script engine's 256 KiB limit on a
script. A field's first two `<items>` (a field has two at most, p. 726)
are kept as lists: each counts against the budget as one element, and
its entries, outside it, are bounded by the list's own limits (1,000
entries), as the field's lists are; a further `<items>` (one public form
has a third, empty one) is an ordinary property, every entry charged.
The tree is shared through an Arc by the template, every instance of the
object and every script's snapshot, and the engine keeps one converted
copy of each tree in the form's template, so it is made once per opened
form rather than per event or per script, behind the form's own lock,
and goes when the form is closed. On a 10,000-field form with 17
property elements each, a script's snapshot takes 3.24 ms against
1.46 ms on main (median of 15 interleaved runs); the property trees are
converted once per form, and each later snapshot adds one walk over the
form and a lookup per tree.
In JavaScript a property object is a Proxy naming its node and path. It
reads what the template writes, or the default XFA 3.3 gives for the
properties the 367 inventoried public forms read: presence of border,
edge, corner, fill (p. 629, 687, 663, 734); colours (a fill white,
p. 734; a font fill, edges and corners black, p. 734, 687, 663); font
typeface/size/weight (pp. 741-744); para hAlign/vAlign (p. 800); margin
insets (p. 776); validate nullTest/scriptTest/formatTest (pp. 880-881);
occur min/max/initial (p. 786); break before/after (p. 632); bind ref
(p. 623); keep next/previous none and intact by container (pp. 762-763);
a comb's numberOfCells (p. 657); a gradient's type (p. 770, 806, 820); a
fill's type solid (p. 734) and a field's widget textEdit (p. 877); a
calculation's override, "disabled" where the template has no calculate
element and "error" where it has one that doesn't say (pp. 641-642).
Where XFA 3.3 leaves the outcome open, or where every reader tested
contradicts its text, reads follow the readers. A border's presence
reads "hidden" when the object has no border element ("visible" with
one that doesn't say, p. 629). For the edges and corners a border
doesn't list, p. 38 says the last one listed is reused; readers draw
such a side from the edge or corner on the same side instead (two
listed alternate, three reuse the second for the fourth, one serves
every side), and reads follow the readers: a side past those listed
reads as the one drawn there (with none listed, the defaults: black,
0.5pt, solid, a square cap, a corner's radius 0in, square join, not
inverted, p. 687, p. 663), and is made as a copy of it (with what a
script already set on it) when a script reaches it, so a write then
changes only that side. A border has four sides: a fifth or later edge
or corner, even one the template lists, is out of reach, as the layout
draws four. The copies have their own budget (8 MiB a run), apart from
the run's text budget, so reaching sides never crowds out a script's
writes; past it a side still reads as the one drawn there. A fill type
the fill doesn't have (its pattern when it is solid, p. 734) can't be
read: the read raises an error the script can catch (GeneralError,
"operation failed"), the fill keeps its type, and `"pattern" in fill`
is false. Anything else unspecified reads as an empty value that
compares as "" and never throws; further defaults are added as forms
need them.
Property objects have className, oneOfChild, children by name
(desc.vMandatory, message.scriptTest) and by class (ui["#textEdit"]),
getElement(name, index), isPropertySpecified, getAttribute,
setAttribute, rawValue (a desc's first text), a validate's or
calculate's script text and message (p. 827), resolveNode(s),
somExpression and nodes (length/item/namedItem); nodes get getAttribute,
getElement, isPropertySpecified, and mandatory, validationMessage,
formatMessage and mandatoryMessage read from their validate's nullTest
and message texts (the text of that name, as the template or a script
named it, else a single unnamed one, which serves every test: p. 782,
p. 880). Setting a test's message sets its named text, else overwrites a
single unnamed one (so every test then reads it), else adds a text of
that name, as readers do; reading validate.message.<test> where no such
text exists raises a script error, as in readers. fillColor, fontColor
and borderColor read from the border fill, font fill and first edge
colours; borderColor set colours all four edges. A child of the same
name comes before a property, an attribute or a member, on nodes and on
property objects alike, for reads and writes (a write to a member a
child shadows leaves the property alone), as XFA SOM resolves name
clashes in favour of the explicitly named object, and `#name` reaches
the property (p. 99). A SOM expression from a node, or from
xfa.resolveNode, that runs into objects' properties
(resolveNode("ui.#textEdit"), "border.edge[1]",
"row[*].border.fill.color", "extras.arrayGroup[*].groupName", "ui.*")
gives those property objects, each instance's, 4,096 at most (about 30
of the forms do this); `value.#text` on a draw stays null, as before,
until a node's value is its value element.
Writes (border.fill.color.value = ..., validate.nullTest = ...,
rawValue, setAttribute, mandatory, validationMessage = ...) are kept for
the rest of the script run and read back through the same properties,
bounded by the run's text budget and 64 attributes a property, but have
no effect on the form yet.
The caption (XFA 3.3 p. 643) and a field's items (p. 758) are property
objects too. A caption's value holds its label as text or rich text,
read by name, by class (`['#text']`), through resolveNode and
oneOfChild; rich text reads as its words; the content a value doesn't
hold reads as null (one of them, p. 598). Items list their entries
through `nodes`, each with the text the choice list or check button
shows. A field's `items` is its first `<items>` (the shown list), a
second is reached by index (`items[1]`), and a field with none reads an
items object with no entries. Items reads follow the template:
`HNode::items` is the hook PdfCraft's choice-list methods (addItem,
clearItems; not on main yet) will set, so that reads agree with them
once they land. exData.loadXML and saveXML do nothing yet. Over the 163
dynamic forms (118,208 objects) no object reaches the budget: elements
p99 30, max 41 (limit 256); bytes p99 773, max 8,500 (limit
16 KiB); item entries per list p99 51, max 342. The choice-list
methods (selectedIndex reads still give an object that takes anything),
execEvent and script objects are handled separately. The one known gap
is value.text and value.exData (about 15 of the 367 forms): a node's
value stays its raw value, and changing what value returns is left to a
follow-up with a full-corpus regression check. FormCalc does not read
these properties yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
pdfcraft-engine
The façade every frontend talks to. It holds the open documents, their edit history and the tool catalogue; frontends never reach past it into the parsing, rendering or editing crates.
word_count::count provides cancellable, page-at-a-time Unicode word and character statistics
with optional semantic exclusions; see word-count behavior and limits.
- Layer: L6. It depends on most of L1–L4 and on nothing above itself. No UI toolkit —
egui,winit,eframeandrfdare forbidden below L7, andxtask layersenforces it. - The crate denies
unwrap/expect/panic!/todo!. Entry points that run untrusted-document code keep a last-resortcatch_unwindguard so an escaped panic becomes an error and the user keeps their document.
Two frontends exist on top of it today: pdfcraft-ui-egui (desktop and web) and
pdfcraft-automation (headless tools, the CLI and MCP). Anything one can do, the other must be
able to do — see AGENTS.md §3.
When dynamic XFA layout is active, JsRunner does not initialize Acrobat JavaScript with
scripts from the PDF container's /Names /JavaScript tree. Those scripts belong to the
fallback PDF view and can otherwise show a misleading viewer-compatibility warning.
They remain in the document and in Document::document_scripts. Requested console scripts,
field actions and XFA events can still show messages; ordinary PDFs, static XFA and shells
without an active layout keep their document scripts. Converting XFA to a static form and
undoing that conversion switches the context with the document state.
The editing model
Each open document holds a pdfcraft_cos::Document: the object graph plus a copy-on-write overlay.
- An edit runs on a clone. On success the previous state is pushed onto the undo stack. Clones share all unchanged data, so a snapshot is cheap — this is what makes 100 levels of undo affordable on a 200 MB file.
- After every edit the working file is produced by an incremental write: the original bytes plus one appended revision.
- The view is refreshed from those bytes, so what you see is exactly what Save will write.
- Saving rebases onto the written bytes, so the next save appends only new edits.
Consequences worth preserving: the original bytes of an opened file are never rewritten in place; a full rewrite happens only where it must (redaction, PDF/A conversion, print imposition, a changed password); and unknown data survives a round trip because nothing is reconstructed that wasn't understood.
API
let mut s = Session::new();
let id = s.open("report.pdf", Some(path), bytes, None)?; // -> DocId
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 })?;
let label = s.undo(id)?; // "Rotate pages"
s.redo(id)?;
let doc = s.get(id).ok_or("gone")?; // &Document: queries, never edits
doc.can_undo(); doc.editable(); doc.allows_printing();
doc.text_blocks(page); doc.page_boxes(page); doc.security_summary();
let bytes = s.save_bytes(id)?; // incremental: keeps signatures over earlier revisions valid
let bytes = s.save_full_bytes(id)?; // full rewrite, garbage-collected
s.mark_saved(id, bytes.clone(), path)?;
Edit is one enum of 75 variants — every undoable change in the product, from
RotatePages to AddAnnotation, SetFieldValue, ApplyRedactions and AddWatermark. Adding a
feature means adding a variant and handling it in apply, which is what keeps undo, the dirty
flag, autosave and the automation tools consistent for free.
Session also owns the operations that produce a new document rather than editing one:
create_blank, create_from_images, create_from_text, combine, extract, split,
split_by_size, reduced_bytes, optimized_bytes, print_pdf, export_data, and the signing
family (sign, sign_with_timestamp, timestamp_document, embed_ltv).
Modules
| Module | What it holds |
|---|---|
commands |
The command registry: id, label, menu, shortcut, and a Needs precondition encoding document security. Menus, the ⌘K palette, shortcuts and the control channel all read it, so every surface agrees on what exists and when it is enabled |
catalog |
Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an Availability of Ready, Planned(milestone) or Provider. The UI renders what is not built yet, honestly |
actions |
PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
links |
Link creation, properties, and finding URLs in page text |
js |
The bridge to pdfcraft-js. forms never depends on js directly; the engine injects it, so a build without JavaScript is possible |
xfa, ocr, compare, export |
Façades over the L3/L4 crates of the same names |
Not done here
- Rendering is still borrowed.
pdfcraft-renderwrapshayroand inspects throughlopdf(ADR-0004). The engine only seesinspect() -> DocInfo,RenderPoolandtext::PageText, and that API stays frozen while M2 replaces what is behind it. - Performance budgets (
misc.performance-budgets) are not set or enforced. - Some shipped features are still reachable only from the UI, not through an
automationtool;cargo xtask paritylists them; closing that gap is tracked in the execution plan (D4).
doc.text_edit_preview(page, block) prepares a read-only page background with the selected
paragraph’s glyphs removed, preserving text positioning, images and colours. It shares the
working document’s render configuration and never enters the undo history.