* Forms: date fields name months in the date language, not always English (#5303) `AFDate_FormatEx("dd-mmm-yy")` wrote "18-Oct-26" in an Italian interface, where a localized Acrobat writes "18-ott-26". Form date formats, `util.printd` and `util.scand` now take their month and weekday names from the date language (Preferences > Date format > Language, which follows the interface language until one is picked), and still read English names typed in. The names are process-wide, so their test runs in its own binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: dates read back in every date language (#5303) Trying every date language showed what a field wrote not always reading back: names holding digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs (Thai, Hindi, Telugu) fell apart word by word, and Italian, Spanish, Romanian and Albanian share some short weekday and month names ("mar"). The language's names are now found whole in the text first, the longest at each place, and read as their English names; the format's order of `mmm` and `ddd` settles a shared name. Both AFDate parsing and util.scand do this, on text up to 1 KB. A test reads back every language, each name format, and hostile text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: date names no field font can draw are drawn in English (#5303) With the names in the date language, a Russian dd-mmm-yy field drawn in Helvetica showed "18-????.-26": no /DR font held "окт." and the WinAnsi stand-in drew each letter as ?. The same held for Japanese, Chinese, Greek, Arabic, Thai and others, and for Latin names outside WinAnsi (Czech "říj"). Before, the names were English and always drew. An AFDate/AFTime field now formats its value again with English names for a widget whose fonts can't draw the localized text; a custom Format script is run again with util.printd writing English names for such a widget, and its result kept only when it draws whole. A field whose font holds the names keeps them, and scripts otherwise still print the date language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: an English weekday before the month keeps the month (#5303) With Italian dates, "Tue 17 Mar 2026" in a "ddd d mmm yyyy" field read as no date: the language's names were found in the text, but not English ones, so "Tue" left the format's weekday unfilled and "Mar", which Italian shares between Tuesday and March, was taken as the weekday. Both AFDate parsing and util.scand lost the month, and Spanish, Romanian and Albanian ("mar") did the same. English names are now found too and fill their field of the format as localized ones do. A test reads English and mixed weekdays before the month in those languages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: an English date run is drawn even when it prints the value (#5303) A Helvetica field storing "18-Oct-26" whose Format script prints it again as dd-mmm-yy still showed "18-???.-26" in Russian. The English run printed "18-Oct-26", but it was dropped for equalling the stored value, which was how a failed run was told apart: a failing script handed back the value unchanged. The English run now says whether it ran (`None` when the script failed or the runner can't write English dates), and what it made is drawn whenever it draws whole. A test reprints the stored value under Russian, and checks that a failing script keeps its text and is reported undrawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: a short weekday typed without its dot is the weekday (#5303) With French dates, "mar 6 janv 2026" in a "ddd d mmm yyyy" field read as no date through util.scand, and "mar 06/01/2026" in "ddd dd/mm/yyyy" through AFDate too. French writes Tuesday as "mar.", which a name search only found with its dot; since English names are found as well, the English "Mar" (March) was the only name of that length, so the weekday became a month. Romanian "mar" did the same. A short name ending in a dot is now also found typed without it, as the same kind of name, so the format's order of name fields decides between it and an equal name of the other kind. Both copies of the name search change. A test reads French and Romanian weekdays typed without their dot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: a shared name is the month when no weekday is typed (#5303) With Italian dates, "17 mar 2026" in a "ddd d mmm yyyy" field read as no date, nor did "mar 17, 2026" in "dddd, mmmm d, yyyy". AFDate leaves weekdays out of what it reads (they are shown, not typed), but the name search still expected the format's weekday first, so "mar", which Italian shares between Tuesday and March, filled it and left no month. Spanish and Albanian "mar" and Albanian "sht" (Saturday, September) did the same, and the keystroke check refused those dates. Where such a read finds no date, the names are now read again with no weekday expected, so the shared name is the month; a typed weekday still comes first ("mar 17 mar 2026"). util.scand reads its fields in order and needs the weekday, so it is unchanged. A test types those dates with the weekday left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: Turkish names typed in capitals are read (#5303) With Turkish dates, "1 MAYIS 2026" and "1 EKİM 2026" in a d mmmm yyyy field read as no date through AFDate and util.scand, and "SALI 6 OCAK 2026" through util.scand. Names were compared lower-cased, which turns I into i, not the dotless ı of "Mayıs" and "Salı", and İ into i with a combining dot above, which "Ekim" doesn't have. Names and typed text are now folded alike, the capital I and the dotted and dotless i all as i and the combining dot dropped, one piece for each char so positions in the text stay aligned: in both copies of the name search, the AFDate month reader and util.scand's name matcher. English reads as before. A test types Turkish names in capitals and English ones as a check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: Greek names typed in capitals are read again (#5303) Folding names char by char turned a capital Σ into σ, so "ΙΑΝΟΥΆΡΙΟΣ" no longer matched "Ιανουάριος", whose last letter is the final ς. The fold now makes ς a σ on both sides, in the forms and JS parsers alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
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.