From a146648648b1c9e15b0bca596d9752babc401fce Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Thu, 15 Oct 2026 19:52:42 +0300
Subject: [PATCH 01/45] M12: enforce XFA rich-text maxLength (#2570)
---
ROADMAP.md | 5 ++++-
crates/automation/tests/automation.rs | 32 +++++++++++++++++++++++++++
crates/xfa/README.md | 1 +
crates/xfa/src/parse.rs | 10 +++++++--
crates/xfa/src/tests.rs | 26 ++++++++++++++++++++++
docs/roadmap.md | 5 +++--
parity/acrobat-features.toml | 4 ++--
7 files changed, 76 insertions(+), 7 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index fe9723fdf6..c978899529 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
-> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2570: dynamic XFA rich-text character limits; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -156,6 +156,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+
+- **2026-10-15 (M12, #2570):** Dynamic XFA text fields carry `exData/maxLength` into the generated AcroForm, so `form_fields` reports the limit and `form_fill` rejects longer text. Synthetic parser/layout and automation regressions cover limits, Unicode character counting and save/reopen. XFA remains partial; estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
@@ -490,6 +492,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M12/#2570: dynamic XFA exData/maxLength reaches AcroForm field limits; parser/layout and headless fill/save/reopen regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 1e662d8f38..93b1d417ad 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -5210,6 +5210,38 @@ fn xfa_data_files_export_and_import_through_tools() {
assert!(std::fs::read_to_string(dir.join("static.xml")).unwrap().contains("Ada "), "a static XFA form exports its data too");
}
+#[test]
+fn xfa_rich_text_max_length_is_reported_and_enforced_through_tools() {
+ let dir = workdir("xfa-rich-limit");
+ let template = pdfcraft_xfa::fixtures::template_with_data(0, "
")
+ .replace(r#" "#, r#" "#)
+ .replace("", "");
+ std::fs::write(dir.join("rich.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "rich.pdf" }))["doc"].as_u64().unwrap();
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").unwrap();
+ assert_eq!(family["max_length"], 5);
+
+ // The boundary counts Unicode characters, not UTF-8 bytes.
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "familyName": "éàöüñ" } }));
+ ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "before.xml", "what": "fields" }));
+ let before = std::fs::read(dir.join("before.xml")).unwrap();
+ assert!(std::str::from_utf8(&before).unwrap().contains("éàöüñ "));
+ let error = a.call("form_fill", &json!({ "doc": doc, "values": { "familyName": "éàöüñX" } })).unwrap_err();
+ assert!(error.to_string().contains("at most 5 characters"), "{error}");
+ ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "after.xml", "what": "fields" }));
+ assert_eq!(std::fs::read(dir.join("after.xml")).unwrap(), before, "refused edits leave the XFA data unchanged");
+
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
+ ok(&mut a, "doc_close", json!({ "doc": doc }));
+ let doc = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").unwrap();
+ assert_eq!(family["max_length"], 5);
+ assert_eq!(family["value"], "éàöüñ");
+}
+
#[test]
fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() {
let dir = workdir("xfa");
diff --git a/crates/xfa/README.md b/crates/xfa/README.md
index 5d494d7b44..db5c4cb28e 100644
--- a/crates/xfa/README.md
+++ b/crates/xfa/README.md
@@ -17,6 +17,7 @@ the automation tools) works on it unchanged. The engine does this when it opens
page areas, content areas and media; measurements in mm, cm, in, pt and px; fonts, paragraphs,
margins, borders (edge order rules), captions with reserves, check-button items, pictures,
rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields),
+ text-field limits (`text/maxChars` and `exData/maxLength`, carried into AcroForm `/MaxLen`),
inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
leaders, `columnWidths` and `colSpan`.
- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index f6c69789b4..7a894ab492 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -424,8 +424,14 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field {
Some(_) => Ui::Unknown,
};
let value_el = child(n, "value");
- let max_chars =
- value_el.and_then(|v| child(v, "text")).and_then(|t| t.attribute("maxChars")).and_then(|m| m.trim().parse::().ok()).filter(|m| *m > 0);
+ let max_chars = value_el
+ .and_then(|v| child(v, "text"))
+ .and_then(|t| t.attribute("maxChars"))
+ .and_then(|m| m.trim().parse::().ok())
+ // XFA 3.3, exData/maxLength: a character limit excluding markup; -1 is unlimited.
+ .or_else(|| value_el.and_then(|v| child(v, "exData")).and_then(|e| e.attribute("maxLength")).and_then(|m| m.trim().parse::().ok()))
+ // Preserve the existing zero/unbounded convention of generated form fields.
+ .filter(|m| *m > 0);
let scripts = parse_events(n);
let numeric = matches!(kind, Ui::NumericEdit)
|| value_el.is_some_and(|v| v.children().any(|c| c.is_element() && matches!(c.tag_name().name(), "decimal" | "integer" | "float")));
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 1e8730586d..72c58abb1f 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -74,6 +74,32 @@ fn the_template_parses_with_fonts_captions_and_rich_text() {
assert!(last.break_before_page);
}
+#[test]
+fn rich_text_max_length_reaches_the_field_and_layout() {
+ for (attribute, expected) in [
+ (r#"maxLength="5250""#, Some(5250)),
+ (r#"maxLength=" 10500 ""#, Some(10500)),
+ (r#"maxLength="0""#, None),
+ (r#"maxLength="-1""#, None),
+ (r#"maxLength="invalid""#, None),
+ (r#"maxLength="999999999999999999999999999999999999""#, None),
+ ("", None),
+ ] {
+ let xml = template(0).replace(
+ r#" "#,
+ &format!(r#"Hello
"#),
+ );
+ let (tpl, _) = parse(&xml).unwrap();
+ let Node::Subform(head) = &tpl.root.children[0] else { panic!() };
+ let Node::Field(field) = &head.children[2] else { panic!() };
+ assert_eq!(field.max_chars, expected, "{attribute}");
+ assert_eq!(field.value.plain().as_deref(), Some("Hello"));
+ let form = layout_xml(&xml).unwrap();
+ let field = form.pages.iter().flat_map(widgets).find(|w| w.name == "familyName").unwrap();
+ assert_eq!(field.max_chars, expected, "{attribute}");
+ }
+}
+
#[test]
fn the_xdp_template_wins_over_the_config_packets_template_element() {
// The config packet has a too; the real one is in the xfa-template namespace.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 3148823ea5..7dd4fd1f48 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# PdfCraft roadmap: milestones and what's next
-> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2570: dynamic XFA rich-text character limits; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
@@ -128,7 +128,7 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Dynamic XFA rich-text fields carry `exData/maxLength` into the AcroForm for reported and enforced character limits (#2570). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
@@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M12/#2570: dynamic XFA exData/maxLength reaches AcroForm field limits; parser/layout and headless fill/save/reopen regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 533013b547..88ad3e0e1c 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5220,8 +5220,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
-notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
+evidence = ["crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
+notes = "Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). Laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"
From f06bebd520d507966a5ae387098cf510068e26da Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:08:05 +0300
Subject: [PATCH 02/45] M12: dispatch repeated XFA print buttons by their own
field (#2572)
---
ROADMAP.md | 5 ++++-
crates/automation/tests/automation.rs | 26 ++++++++++++++++++++++++++
crates/engine/src/js.rs | 3 ++-
crates/engine/src/tests.rs | 13 +++++++++++++
crates/engine/src/xfa.rs | 10 ++++++----
crates/xfa/README.md | 4 +++-
crates/xfa/src/fixtures.rs | 25 +++++++++++++++++++++++++
docs/roadmap.md | 5 +++--
parity/acrobat-features.toml | 4 ++--
9 files changed, 84 insertions(+), 11 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index fe9723fdf6..e683356481 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
-> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2572: repeated XFA print buttons dispatch to their shared click script; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -156,6 +156,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+
+- **2026-10-15 (M12, #2572):** Every copy of a page-master button dispatches to its XFA click script by the clicked field's `/PCSom`, including early copies that share a SOM with later pages. Synthetic engine and automation regressions cover the print script using `numPages`, all three copies and save/reopen. XFA remains partial; estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
@@ -490,6 +492,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M12/#2572: repeated page-master buttons use the clicked field's SOM for XFA script dispatch; synthetic engine/tool regressions, including save/reopen. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 1e662d8f38..74bc6a28e7 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -5404,6 +5404,32 @@ fn measurement_snap_tool_covers_all_targets() {
let _ = std::fs::remove_dir_all(dir);
}
+#[test]
+fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
+ let dir = workdir("xfa-print-buttons");
+ let template = pdfcraft_xfa::fixtures::repeated_print_button_template();
+ std::fs::write(dir.join("print.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "print.pdf" }))["doc"].as_u64().unwrap();
+ let check = |a: &mut Automation, doc| {
+ let fields = ok(a, "form_fields", json!({ "doc": doc }));
+ let buttons: Vec<_> = fields["fields"].as_array().unwrap().iter().filter(|f| f["type"] == "button").collect();
+ assert_eq!(buttons.len(), 3);
+ for (index, field) in buttons.iter().enumerate() {
+ assert_eq!(field["page"], index + 1);
+ let out = ok(a, "js_run", json!({ "doc": doc, "field": field["name"], "script": "throw new Error('AcroForm fallback ran')" }));
+ assert!(out["error"].is_null(), "{out}");
+ assert_eq!(out["requests"], json!([{ "print": true }]), "{out}");
+ assert_eq!(out["alerts"], json!([]));
+ }
+ };
+ check(&mut a, doc);
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
+ ok(&mut a, "doc_close", json!({ "doc": doc }));
+ let doc = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap();
+ check(&mut a, doc);
+}
+
#[test]
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
let dir = workdir("xfa-scripts");
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index 8958688d2d..0a7879edab 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -192,7 +192,8 @@ impl Session {
// A button PdfCraft generated from an XFA template runs its XFA click script.
if let Some(t) = target
&& let Some(tpl) = doc.xfa_template.clone()
- && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, t)
+ && let Some(field) = doc.form.iter().find(|f| f.name == t)
+ && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field)
{
self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?;
let out = self.take_js_output(id);
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ed90eea411..505e547f04 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -3855,6 +3855,19 @@ fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]);
}
+#[test]
+fn repeated_xfa_print_buttons_run_their_click_script() {
+ let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::repeated_print_button_template()));
+ let mut s = Session::new();
+ let id = s.open("print.pdf", None, bytes, None).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
+ for name in ["PrintButton1", "PrintButton1_2", "PrintButton1_3"] {
+ let out = s.run_javascript(id, "throw new Error('AcroForm fallback ran')", Some(name)).unwrap();
+ assert_eq!(out.error, None, "{name}: {out:?}");
+ assert_eq!(out.requests, vec![pdfcraft_js::Request::Print], "{name}: {out:?}");
+ }
+}
+
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 0f37933afd..a127bf5b24 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -826,10 +826,12 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
r.finish().map(|_| ())
}
-/// The SOM path of field `name` when PdfCraft generated it from an XFA template and the
-/// template has a click script for it (a button with a native action has none).
-pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, name: &str) -> Option {
- let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, f)| f == name).map(|(s, _)| s)?;
+/// The SOM path of a generated field whose template has a click script.
+pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> Option {
+ // Page-master copies have distinct PDF field names but share a SOM path. Reversing
+ // fields_by_som would lose every copy except the last: use the clicked field itself.
+ let object = doc.get(field.obj);
+ let som = object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string()?.to_text();
let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc));
live.events.iter().any(|e| e.som == som && e.activity == "click").then_some(som)
}
diff --git a/crates/xfa/README.md b/crates/xfa/README.md
index 5d494d7b44..0b6e77eca2 100644
--- a/crates/xfa/README.md
+++ b/crates/xfa/README.md
@@ -59,7 +59,9 @@ be measured exponentially often in their nesting. A measurement budget backs thi
the datasets packet per event), and `rerender`, which lays the form out again from template,
data and overrides. The scripts themselves run in the `js` crate's XFA object model; the
engine runs initialize and calculate on open, change, exit, validate and calculate after a
- field changes, and click for buttons.
+ field changes, and click for buttons. Every generated copy of a page-master button resolves
+ its own PDF field to the shared XFA click script, so clicking an early copy works as well
+ as the last, including through `js_run` and after save/reopen.
Private keys PdfCraft writes (other viewers ignore them):
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index 54bb9bc216..29119ed558 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -66,6 +66,31 @@ pub fn template(rows: usize) -> String {
t
}
+/// Three body pages sharing a page-master print button and its XFA click script.
+pub fn repeated_print_button_template() -> String {
+ let mut xml = String::from(
+ r#"
+
+
+
+
+ Print form
+
+
+
+"#,
+ );
+ for page in 1..=3 {
+ xml.push_str(&format!(
+ r#"
+ Page {page}
+ "#
+ ));
+ }
+ xml.push_str(" ");
+ xml
+}
+
/// [`template`] with a datasets packet holding `data` (the children of `xfa:data`).
pub fn template_with_data(rows: usize, data: &str) -> String {
let t = template(rows);
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 3148823ea5..d05d7b7f75 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# PdfCraft roadmap: milestones and what's next
-> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2572: repeated XFA print buttons dispatch to their shared click script; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
@@ -128,7 +128,7 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons (#2572). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
@@ -179,6 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M12/#2572: repeated page-master buttons use the clicked field's SOM for XFA script dispatch; synthetic engine/tool regressions, including save/reopen. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 533013b547..03d9550edd 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5220,8 +5220,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
-notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
+evidence = ["crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
+notes = "Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"
From 97d286b2b9725c2f5731256a0be16e4cc0477aef Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:33:03 +0300
Subject: [PATCH 03/45] M12: reflow growable XFA text after form edits (#2569)
---
ROADMAP.md | 3 ++
crates/automation/tests/automation.rs | 33 ++++++++++++++
crates/engine/src/lib.rs | 14 +++++-
crates/engine/src/tests.rs | 65 +++++++++++++++++++++++++++
crates/engine/src/xfa.rs | 43 ++++++++++++++++++
crates/xfa/README.md | 9 +++-
crates/xfa/src/fixtures.rs | 23 ++++++++++
crates/xfa/src/layout.rs | 4 +-
crates/xfa/src/lib.rs | 4 +-
crates/xfa/src/model.rs | 5 +++
crates/xfa/src/script.rs | 6 +++
docs/roadmap.md | 6 ++-
parity/acrobat-features.toml | 4 +-
13 files changed, 209 insertions(+), 10 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index fe9723fdf6..e323ac0a92 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,6 +2,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -156,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+- **2026-10-15 (M12, #2569):** Editing growable multiline XFA text now lays out pages and widgets from the final datasets, also on forms without scripts. Undo/redo restores the layout; synthetic engine/tool tests cover 10,500 characters, save/reopen and print-ready PDF text. Text longer than a page uses the existing read-only page text fallback, with a warning. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
@@ -490,6 +492,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index 1e662d8f38..61153f7043 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -5404,6 +5404,39 @@ fn measurement_snap_tool_covers_all_targets() {
let _ = std::fs::remove_dir_all(dir);
}
+#[test]
+fn growable_xfa_text_paginates_after_fill_save_and_print() {
+ let dir = workdir("xfa-growable-text");
+ let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect();
+ assert_eq!(text.len(), 10_500);
+ for scripted in [false, true] {
+ let template = pdfcraft_xfa::fixtures::growable_text_template(scripted);
+ std::fs::write(dir.join("growing.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap();
+ let mut a = auto(&dir);
+ let opened = ok(&mut a, "doc_open", json!({ "path": "growing.pdf" }));
+ let doc = opened["doc"].as_u64().unwrap();
+ assert_eq!(opened["pages"], 2);
+ let filled = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "ACTIVIDADES": text } }));
+ assert_eq!(filled["pages"], 3, "scripted={scripted}: {filled}");
+ ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" }));
+ let data = std::fs::read_to_string(dir.join("data.xml")).unwrap();
+ assert!(data.contains(text.trim_end()), "the full value remains in the datasets");
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
+ ok(&mut a, "doc_close", json!({ "doc": doc }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }));
+ assert_eq!(reopened["pages"], 3);
+ let doc = reopened["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" }));
+ let printed = ok(&mut a, "doc_open", json!({ "path": "printed.pdf" }));
+ assert_eq!(printed["pages"], 3);
+ let output = ok(&mut a, "text_extract", json!({ "doc": printed["doc"] }));
+ let printed_text = output["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap()).collect::>().join("\n");
+ let expected = format!("{text}\nFollowing section");
+ assert_eq!(printed_text.split_whitespace().collect::>(), expected.split_whitespace().collect::>(), "scripted={scripted}");
+ }
+ let _ = std::fs::remove_dir_all(dir);
+}
+
#[test]
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
let dir = workdir("xfa-scripts");
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 01f162ae7d..0fc619a983 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -3599,6 +3599,7 @@ impl Session {
doc.edit_notices.clear();
let name = doc.name.clone();
let is_xfa = doc.info.xfa.is_some();
+ let mut scope = scope_of(&edit);
let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48));
if !js_off {
cx.xfa = doc.xfa_template.clone();
@@ -3718,6 +3719,18 @@ impl Session {
xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
.map_err(EditError::Write)?;
+ // Rebuilding from stale data would discard accepted widget values.
+ if xfa_notes.is_empty() {
+ let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form))
+ .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})")))
+ .map_err(EditError::Write)?;
+ if let Some(warnings) = reflow {
+ xfa_notes.extend(warnings);
+ // Layout replaces pages and widgets, even if the page count is unchanged.
+ // Undo/redo must rebuild these too instead of refreshing only field values.
+ scope = Scope::Full;
+ }
+ }
}
// Signed documents are only ever saved incrementally: an edit that needs a full rewrite
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
@@ -3736,7 +3749,6 @@ impl Session {
let previous = std::mem::replace(&mut editor.cos, next);
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
- let scope = scope_of(&edit);
editor.undo.push((label, previous, previous_keys, scope));
if editor.undo.len() > MAX_UNDO {
editor.undo.remove(0);
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ed90eea411..c17cca4e4b 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -3855,6 +3855,71 @@ fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]);
}
+#[test]
+fn filling_growable_xfa_text_reflows_and_undo_restores_the_layout() {
+ let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect();
+ assert_eq!(text.len(), 10_500);
+ for scripted in [false, true] {
+ let template = pdfcraft_xfa::fixtures::growable_text_template(scripted);
+ let mut s = Session::new();
+ let id = s.open("growing.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
+ s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 3, "scripted={scripted}");
+ let data = pdfcraft_xfa::data_of(&s.get(id).unwrap().editor.as_ref().unwrap().cos).unwrap();
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].Page3[0].ACTIVIDADES[0]")), Some(text.as_str()));
+ s.undo(id).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
+ assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec!["Short"]);
+ s.redo(id).unwrap();
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
+ }
+}
+
+#[test]
+fn xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets() {
+ for fixed in [false, true] {
+ let size = if fixed { r#"h="30pt""# } else { r#"minH="20pt""# };
+ let template = pdfcraft_xfa::fixtures::growable_text_template(false).replace(r#"minH="207.017mm""#, size);
+ let mut s = Session::new();
+ let id = s.open("geometry.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone();
+ let before = field(&s);
+ s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text("Line\n".repeat(10)) }).unwrap();
+ let after = field(&s);
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 2);
+ if fixed {
+ assert_eq!(before.obj, after.obj, "fixed-height fields must not trigger layout");
+ assert_eq!(before.widgets[0].rect, after.widgets[0].rect);
+ } else {
+ assert_ne!(before.obj, after.obj);
+ assert!(after.widgets[0].rect[3] - after.widgets[0].rect[1] > before.widgets[0].rect[3] - before.widgets[0].rect[1]);
+ assert_eq!(
+ s.get(id).unwrap().info.fields.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value.as_deref(),
+ Some("Line\n".repeat(10).trim_end())
+ );
+ }
+ s.undo(id).unwrap();
+ assert_eq!(field(&s).widgets[0].rect, before.widgets[0].rect);
+ s.redo(id).unwrap();
+ assert_eq!(field(&s).widgets[0].rect, after.widgets[0].rect);
+ }
+}
+
+#[test]
+fn xfa_text_that_cannot_sync_to_structured_data_is_not_replaced_by_a_relayout() {
+ let template = pdfcraft_xfa::fixtures::growable_text_template(false)
+ .replace("Short ", "Short ");
+ let mut s = Session::new();
+ let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ let text = "Keep this accepted value\n".repeat(150);
+ s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap();
+ let doc = s.get(id).unwrap();
+ assert_eq!(doc.info.pages.len(), 2);
+ assert_eq!(doc.form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec![text]);
+ assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
+}
+
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 0f37933afd..7db89defad 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -764,6 +764,49 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>,
}
}
+/// Reflow a laid-out XFA form when an edited multiline field can grow. The datasets must
+/// already hold the final values, including calculation results. Fixed-height fields keep
+/// their existing geometry; forms without scripts parse their template only when needed.
+/// `Some` means pages/widgets were rebuilt and the caller must fully refresh its snapshot.
+pub(crate) fn relayout_changed_text(
+ doc: &mut pdfcraft_cos::Document,
+ cached: Option<&Template>,
+ before: &[pdfcraft_forms::Field],
+) -> Result>, String> {
+ if pdfcraft_xfa::existing_layout(doc).is_none() {
+ return Ok(None);
+ }
+ let current = &*doc;
+ let mut changed = before
+ .iter()
+ .filter(|f| f.kind == pdfcraft_forms::FieldKind::Text && f.has(pdfcraft_forms::flags::MULTILINE))
+ .filter_map(|f| {
+ let object = current.get(f.obj);
+ let dict = object.as_dict()?;
+ let value = dict.get(b"V").and_then(|v| current.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
+ if f.value.first().map_or("", String::as_str) == value {
+ return None;
+ }
+ dict.get(pdfcraft_xfa::SOM_KEY).and_then(|s| current.resolve(s).as_string().map(|s| s.to_text()))
+ })
+ .peekable();
+ if changed.peek().is_none() {
+ return Ok(None);
+ }
+ let template = match cached {
+ Some(tpl) => std::borrow::Cow::Borrowed(tpl),
+ None => std::borrow::Cow::Owned(pdfcraft_xfa::template_of(current).map_err(|e| e.to_string())?),
+ };
+ let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som));
+ drop(changed);
+ if !reflow {
+ return Ok(None);
+ }
+ let report = pdfcraft_xfa::rerender(doc, &template).map_err(|e| e.to_string())?;
+ crate::xfa_redraw(doc)?;
+ Ok(Some(report.warnings))
+}
+
/// On open: initialize scripts, then calculations. Returns what they changed.
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
diff --git a/crates/xfa/README.md b/crates/xfa/README.md
index 5d494d7b44..1d835c4e0e 100644
--- a/crates/xfa/README.md
+++ b/crates/xfa/README.md
@@ -7,7 +7,14 @@ A dynamic XFA form is a PDF shell around an XML template (`/AcroForm /XFA`): one
("requires Adobe Reader") and `/NeedsRendering true`. Viewers without an XFA engine show the
placeholder. This crate reads the template, lays it out, and writes ordinary pages and AcroForm
fields into the document, so everything else in PdfCraft (rendering, filling, saving, printing,
-the automation tools) works on it unchanged. The engine does this when it opens such a form.
+the automation tools) works on it unchanged. The engine does this when it opens such a form,
+and repeats layout after an edited multiline text field without a fixed height changes value.
+This uses the final datasets after calculations, including on forms without scripts; pages
+and widgets refresh together and undo/redo restores their layout. Text longer than a page
+uses the existing page-text fallback: the full value remains in the datasets and is shown
+across pages, with a warning that it can no longer be edited as a widget. Fixed-height fields
+keep their geometry. If a value cannot be synchronized to the datasets, the engine reports
+that warning and keeps the existing layout and accepted widget values.
## What it does
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index 54bb9bc216..ba9b9dec79 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -66,6 +66,29 @@ pub fn template(rows: usize) -> String {
t
}
+/// A growable rich-text field followed by a section that starts on a new page.
+pub fn growable_text_template(scripted: bool) -> String {
+ let event = if scripted { r#" "# } else { "" };
+ format!(
+ r#"
+{event}
+
+
+
+
+
+
+
+
+
+ Following section
+
+
+
+ "#
+ )
+}
+
/// [`template`] with a datasets packet holding `data` (the children of `xfa:data`).
pub fn template_with_data(rows: usize, data: &str) -> String {
let t = template(rows);
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index a491d3f493..c77e9f492b 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1047,7 +1047,7 @@ impl Layouter<'_> {
/// template's), for a field without a fixed height, which grows to hold its text. `ctx`
/// is the field's own (its SOM ends in the field). Zero for anything else.
fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 {
- if f.ui != Ui::TextEdit || !f.multiline || f.common.h.is_some() || f.common.name.is_none() {
+ if !f.grows_with_text() || f.common.name.is_none() {
return 0.0;
}
let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain());
@@ -1551,7 +1551,7 @@ impl Layouter<'_> {
}
// A text field that grew past what a content area holds can't be one widget: its text
// is drawn as lines the page breaks fall between, and stays what the data holds.
- if f.ui == Ui::TextEdit && f.multiline && f.common.h.is_none() && rect.h > self.cursor().0.h + 0.01 {
+ if f.grows_with_text() && rect.h > self.cursor().0.h + 0.01 {
let value =
self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som_path()))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain());
if let Some(value) = value.filter(|t| !t.trim().is_empty()) {
diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs
index bf4414970e..bfb0e46177 100644
--- a/crates/xfa/src/lib.rs
+++ b/crates/xfa/src/lib.rs
@@ -100,8 +100,8 @@ pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_p
pub use parse::{measure, parse};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
pub use script::{
- FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, fields_by_som, form_tree,
- has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,
+ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text,
+ fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of,
};
#[derive(Debug, thiserror::Error)]
diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs
index f4ef3fabce..8fbf9f00dc 100644
--- a/crates/xfa/src/model.rs
+++ b/crates/xfa/src/model.rs
@@ -506,6 +506,11 @@ pub struct Field {
}
impl Field {
+ /// Whether the text can determine this field's height.
+ pub(crate) fn grows_with_text(&self) -> bool {
+ self.ui == Ui::TextEdit && self.multiline && self.common.h.is_none()
+ }
+
/// A choice list's value as saved to the data: an item's shown text becomes its saved
/// value (an editable list keeps typed text); several values stay on separate lines.
/// Other fields keep `v`.
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 17a0376641..c664bf2562 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -614,6 +614,12 @@ fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> {
}
}
+/// Whether the template field at `som` is multiline text without a fixed height.
+/// A change to its value can require a new layout, including new continuation pages.
+pub fn field_grows_with_text(tpl: &Template, som: &str) -> bool {
+ template_field(tpl, som).is_some_and(Field::grows_with_text)
+}
+
/// The child of `nodes` named `name`, looking through unnamed subforms and areas (as data
/// paths do); exclusion groups are not looked into.
fn named_in<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> {
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 3148823ea5..0b170ff0e8 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,5 +1,6 @@
# PdfCraft roadmap: milestones and what's next
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -128,9 +129,9 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
@@ -179,6 +180,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 533013b547..b351184301 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5220,8 +5220,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"]
-notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
+evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print"]
+notes = "Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"
From 84ee69b507f1a7a933f75ad35b345e6bb87d5f25 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sat, 17 Oct 2026 10:13:39 +0200
Subject: [PATCH 04/45] Render month and weekday names in XFA date patterns
Found while testing crates/xfa/src/data.rs:265-281 (iso_to_pattern, with date_tokens at :242 and pattern_to_iso at :285; pattern built in crates/xfa/src/layout.rs:396 date_pattern)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/xfa/src/data.rs | 62 +++++++++++++++++++++++++++++++++++++++--
crates/xfa/src/tests.rs | 16 +++++++++++
2 files changed, 75 insertions(+), 3 deletions(-)
diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs
index d3e1f2c1ae..8462465aa1 100644
--- a/crates/xfa/src/data.rs
+++ b/crates/xfa/src/data.rs
@@ -238,7 +238,33 @@ fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option {
Some(body[..body.find('"')?].to_string())
}
-/// The tokens of a date pattern: `yyyy`, `yy`, `mm`, `m`, `dd`, `d`, or a literal character.
+const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
+const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+/// The weekday (0 = Sunday) of a proleptic Gregorian date.
+fn weekday(y: u32, m: u32, d: u32) -> usize {
+ const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
+ let y = i64::from(y) - i64::from(m < 3);
+ let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0);
+ ((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7)) as usize
+}
+
+/// The index and length of the name in `names` that `text` starts with (ignoring case),
+/// cut to `abbr` letters when that is set.
+fn name_at(text: &str, names: &[&str], abbr: bool) -> Option<(usize, usize)> {
+ names.iter().enumerate().find_map(|(i, n)| {
+ let n = if abbr { n.get(..3)? } else { n };
+ text.get(..n.len()).filter(|t| t.eq_ignore_ascii_case(n)).map(|_| (i, n.len()))
+ })
+}
+
+/// Whether `token` is a run of the date letters (`y`, `m`, `d`) rather than a literal.
+fn is_date_run(token: &str) -> bool {
+ token.starts_with(['y', 'm', 'd'])
+}
+
+/// The tokens of a date pattern: runs of `y`, `m` and `d` (`yyyy`, `yy`, `mmmm`, `mmm`, `mm`,
+/// `m`, `dddd`, `ddd`, `dd`, `d` are understood), or a literal character.
fn date_tokens(pattern: &str) -> Vec {
let mut out = Vec::new();
let chars: Vec = pattern.chars().collect();
@@ -260,8 +286,8 @@ fn date_tokens(pattern: &str) -> Vec {
out
}
-/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`). Unknown patterns and
-/// non-dates come back as they are.
+/// `2001-02-03` shown in `pattern` (`mm/dd/yyyy` → `02/03/2001`, `mmm d, yyyy` →
+/// `Feb 3, 2001`). Patterns with a token this doesn't know and non-dates come back as they are.
pub fn iso_to_pattern(iso: &str, pattern: &str) -> String {
let parts: Vec<&str> = iso.trim().splitn(3, '-').collect();
let [y, m, d] = parts[..] else { return iso.to_string() };
@@ -275,6 +301,21 @@ pub fn iso_to_pattern(iso: &str, pattern: &str) -> String {
"m" => out.push_str(&m.to_string()),
"dd" => out.push_str(&format!("{d:02}")),
"d" => out.push_str(&d.to_string()),
+ "mmm" | "mmmm" => {
+ let Some(name) = m.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) else { return iso.to_string() };
+ match t.len() {
+ 3 => out.push_str(name.get(..3).unwrap_or(name)),
+ _ => out.push_str(name),
+ }
+ }
+ "ddd" | "dddd" => {
+ let name = WEEKDAYS.get(weekday(y, m, d)).copied().unwrap_or_default();
+ match t.len() {
+ 3 => out.push_str(name.get(..3).unwrap_or(name)),
+ _ => out.push_str(name),
+ }
+ }
+ other if is_date_run(other) => return iso.to_string(),
other => out.push_str(other),
}
}
@@ -285,6 +326,7 @@ pub fn iso_to_pattern(iso: &str, pattern: &str) -> String {
pub fn pattern_to_iso(text: &str, pattern: &str) -> Option {
let text = text.trim();
let (mut y, mut m, mut d) = (None, None, None);
+ let mut day_name = None;
let mut rest = text;
let tokens = date_tokens(pattern);
for (i, t) in tokens.iter().enumerate() {
@@ -310,6 +352,17 @@ pub fn pattern_to_iso(text: &str, pattern: &str) -> Option {
_ => d = Some(v),
}
}
+ "mmm" | "mmmm" => {
+ let (i, len) = name_at(rest, &MONTHS, t.len() == 3)?;
+ m = Some(i as u32 + 1);
+ rest = rest.get(len..)?;
+ }
+ "ddd" | "dddd" => {
+ let (i, len) = name_at(rest, &WEEKDAYS, t.len() == 3)?;
+ day_name = Some(i);
+ rest = rest.get(len..)?;
+ }
+ run if is_date_run(run) => return None,
lit => {
rest = rest.strip_prefix(lit)?;
}
@@ -320,6 +373,9 @@ pub fn pattern_to_iso(text: &str, pattern: &str) -> Option {
return None;
}
let (y, m, d) = (y?, m?, d?);
+ if day_name.is_some_and(|w| w != weekday(y, m, d)) {
+ return None;
+ }
(1..=12).contains(&m).then(|| format!("{y:04}-{m:02}-{d:02}")).filter(|_| (1..=31).contains(&d))
}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 1e8730586d..c1edf81846 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -336,6 +336,22 @@ fn dates_convert_between_iso_and_acrobat_patterns() {
assert_eq!(pattern_to_iso("hello", "mm/dd/yyyy"), None);
}
+#[test]
+fn month_and_weekday_names_in_date_patterns() {
+ // 2001-02-03 was a Saturday.
+ assert_eq!(iso_to_pattern("2001-02-03", "mmm d, yyyy"), "Feb 3, 2001");
+ assert_eq!(iso_to_pattern("2001-02-03", "mmmm d, yyyy"), "February 3, 2001");
+ assert_eq!(iso_to_pattern("2001-02-03", "ddd, mmm d"), "Sat, Feb 3");
+ assert_eq!(iso_to_pattern("2001-02-03", "dddd, mmmm dd, yyyy"), "Saturday, February 03, 2001");
+ assert_eq!(pattern_to_iso("Feb 3, 2001", "mmm d, yyyy").as_deref(), Some("2001-02-03"));
+ assert_eq!(pattern_to_iso("February 3, 2001", "mmmm d, yyyy").as_deref(), Some("2001-02-03"));
+ assert_eq!(pattern_to_iso("Saturday, February 03, 2001", "dddd, mmmm dd, yyyy").as_deref(), Some("2001-02-03"));
+ assert_eq!(pattern_to_iso("Smarch 3, 2001", "mmm d, yyyy"), None);
+ // A token the converter can't render leaves the value as it is.
+ assert_eq!(iso_to_pattern("2001-02-03", "mmmmm d"), "2001-02-03");
+ assert_eq!(pattern_to_iso("Feb 3", "mmmmm d"), None);
+}
+
#[test]
fn data_fills_the_fields_and_adds_rows() {
let form = layout_xml(&template_with_data(1, DATA)).unwrap();
From fe3e07c244d3b6bbf1772fae31a030da06adc282 Mon Sep 17 00:00:00 2001
From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com>
Date: Sun, 18 Oct 2026 15:07:38 +0800
Subject: [PATCH 05/45] fix(forms): an XFA form whose config forbids dynamic
rendering is static (#3180)
An XFA form with no AcroForm fields was always classified as dynamic, so
opening it laid the template out into new pages and appended them as a
revision: the pages the user saw, and any later save, were PdfCraft's
layout rather than the file's own pages, with nothing saying so. Adobe
Designer writes forbidden in the config
packet of a static PDF form; such a form is now static and its pages are
kept. /NeedsRendering still makes a form dynamic, and with no word from
the config a form without fields stays dynamic as before.
The config packet is found in an /XFA array or a whole-XDP stream, read
with a 16 MiB decode cap, and scanned for the one element.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---
crates/engine/src/tests.rs | 34 +++++++++++
crates/render/src/inspect.rs | 108 +++++++++++++++++++++++++++++++++--
crates/xfa/README.md | 4 +-
3 files changed, 141 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ec305b1ca5..d7f521106c 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -3935,6 +3935,40 @@ fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() {
assert!(!doc.dirty);
}
+/// #3180: an XFA form with no fields whose configuration says `dynamicRender` is `forbidden` is a
+/// static form: its pages are the form. Opening it used to lay the template out into new pages
+/// and write them as a revision, so even a save with no change replaced the pages.
+#[test]
+fn an_xfa_form_without_fields_that_forbids_dynamic_rendering_keeps_its_pages() {
+ let config = r#"forbidden "#;
+ let template = pdfcraft_xfa::fixtures::template(1);
+ let with_config = template.replacen(" plain.len() && doc.bytes.starts_with(&plain[..]), "the layout is a revision");
+}
+
#[test]
fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada 1 "));
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 3c3988b7b5..5893d38ee0 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -30,6 +30,10 @@ const LOAD_STREAM_LIMIT: usize = 256 << 20;
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
+/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to
+/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template.
+const MAX_XFA_CONFIG_BYTES: usize = 16 << 20;
+
/// File specifications retained from a remote-Go-To action are deliberately small.
const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096;
const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
@@ -87,8 +91,8 @@ pub enum Xfa {
/// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold
/// its data, which Acrobat shows in preference to the fields).
Static,
- /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields): the
- /// PDF pages are only a placeholder.
+ /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a
+ /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder.
Dynamic,
}
@@ -663,8 +667,12 @@ impl<'a> Inspector<'a> {
}
info.has_javascript |= info.fields.iter().any(|f| f.has_actions);
let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false);
- if catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).is_some_and(|form| form.get(b"XFA").is_ok()) {
- info.xfa = Some(if needs_rendering || info.fields.is_empty() { Xfa::Dynamic } else { Xfa::Static });
+ if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) {
+ // A form with no fields is taken to be laid out from its template, unless its
+ // configuration says it is a static form (#3180): Designer writes
+ // `forbidden ` for one, whose pages are the form.
+ let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden");
+ info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static });
}
self.layers(catalog, &mut info.layers);
info.layer_groups = self.layer_groups(catalog);
@@ -729,6 +737,28 @@ impl<'a> Inspector<'a> {
}
}
+ /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under
+ /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is
+ /// either one stream holding the whole XDP or an array of packet names and streams (ISO
+ /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over
+ /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element.
+ fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option {
+ let stream = match self.resolve(xfa) {
+ Object::Array(items) => {
+ let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?;
+ self.resolve(items.get(at.checked_add(1)?)?)
+ }
+ whole => whole,
+ };
+ let stream = stream.as_stream().ok()?;
+ let bytes = if stream.dict.get(b"Filter").is_ok() {
+ stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()?
+ } else {
+ stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec()
+ };
+ xml_element_text(&bytes, b"dynamicRender")
+ }
+
fn resolve(&self, o: &'a Object) -> &'a Object {
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
}
@@ -1614,6 +1644,33 @@ pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -
catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p))))
}
+/// The trimmed text of the first `` element in `xml` (any namespace prefix), if it is plain
+/// text. A byte scan, not an XML parser: enough for one known element in an XFA packet, whose
+/// tags Designer writes with a line break before each `>`.
+fn xml_element_text(xml: &[u8], name: &[u8]) -> Option {
+ let mut from = 0;
+ while let Some(lt) = xml.get(from..)?.iter().position(|&b| b == b'<').map(|i| i + from) {
+ from = lt + 1;
+ let tag = xml.get(from..)?;
+ // Skip a namespace prefix (`xfa:name`), then require the name followed by `>` or a space
+ // (a self-closing ` ` holds no text).
+ let local = tag
+ .iter()
+ .position(|&b| b == b':' || b == b'>' || b == b'/' || b.is_ascii_whitespace())
+ .map_or(tag, |i| if tag.get(i) == Some(&b':') { tag.get(i + 1..).unwrap_or_default() } else { tag });
+ let Some(after) = local.strip_prefix(name) else { continue };
+ if !matches!(after.first(), Some(b) if *b == b'>' || b.is_ascii_whitespace()) {
+ continue;
+ }
+ let close = after.iter().position(|&b| b == b'>')?;
+ let body = after.get(close + 1..)?;
+ let end = body.iter().position(|&b| b == b'<')?;
+ let text = std::str::from_utf8(body.get(..end)?).ok()?.trim();
+ return (!text.is_empty()).then(|| text.to_owned());
+ }
+ None
+}
+
#[cfg(test)]
mod tests {
#[test]
@@ -2616,6 +2673,49 @@ trailer << /Root 1 0 R >>
assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]);
}
+ #[test]
+ fn xml_element_text_reads_one_element_as_designer_writes_it() {
+ let text = |xml: &str| xml_element_text(xml.as_bytes(), b"dynamicRender");
+ assert_eq!(text("forbidden ").as_deref(), Some("forbidden"));
+ assert_eq!(text(" required ").as_deref(), Some("required"));
+ // A longer name, a self-closing or empty element, and no element at all give nothing.
+ assert_eq!(text("forbidden "), None);
+ assert_eq!(text(" "), None);
+ assert_eq!(text(" "), None);
+ assert_eq!(text(" "), None);
+ // Cut short anywhere: no panic, nothing read.
+ let whole = "forbidden ";
+ for end in 0..whole.len() {
+ let _ = text(&whole[..end]);
+ }
+ assert_eq!(text("forbidden"), None);
+ }
+
+ /// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were
+ /// replaced by a layout on open, even when its configuration says it is a static form.
+ #[test]
+ fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() {
+ let xfa_of = |config: &str, catalog_extra: &str| {
+ let bodies = [
+ format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
+ "<< /Type /Page /Parent 2 0 R >>".to_string(),
+ format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()),
+ "<< /Length 11 >>\nstream\n \nendstream".to_string(),
+ ];
+ inspect(Arc::new(pdf_of(&bodies)), None).expect("opens").xfa
+ };
+ let config = |value: &str| {
+ format!(
+ r#"{value} "#
+ )
+ };
+ assert_eq!(xfa_of(&config("forbidden"), ""), Some(Xfa::Static));
+ assert_eq!(xfa_of(&config("required"), ""), Some(Xfa::Dynamic));
+ assert_eq!(xfa_of(" ", ""), Some(Xfa::Dynamic), "no field and no word from the config: dynamic, as before");
+ assert_eq!(xfa_of(&config("forbidden"), "/NeedsRendering true"), Some(Xfa::Dynamic), "/NeedsRendering still wins");
+ }
+
/// A file whose objects are `bodies` in order (object 1 first), with a cross-reference table.
fn pdf_of(bodies: &[String]) -> Vec {
let mut out = b"%PDF-1.7\n".to_vec();
diff --git a/crates/xfa/README.md b/crates/xfa/README.md
index 5d494d7b44..310a9b64ac 100644
--- a/crates/xfa/README.md
+++ b/crates/xfa/README.md
@@ -117,7 +117,9 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
engine's own limits end it, and the document's scripts are turned off. Memory a script
allocates is not capped (the same holds for AcroForm JavaScript).
- **Static XFA forms** (`/NeedsRendering` absent, AcroForm fields present) keep their AcroForm;
- only their data is read and written.
+ only their data is read and written. A form with no fields is also static when its `config`
+ packet says `forbidden ` (Designer's static PDF form): its pages
+ are the form and are not laid out again (#3180). Without that, no fields means dynamic.
- **Flattening and converting.** `strip` takes the XFA out of a document: the packets,
`/NeedsRendering`, the layout mark, the overrides and the private keys on the fields. The
engine calls it after flattening an XFA form's fields (so Adobe's viewers show the baked
From 0f870c7b7d277b6394a6edccd38064e1bd3f9cab Mon Sep 17 00:00:00 2001
From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com>
Date: Sun, 18 Oct 2026 15:33:57 +0800
Subject: [PATCH 06/45] fix(forms): read dynamicRender only at
config/acrobat/acrobat7, in UTF-8 or UTF-16 (#3180)
Review follow-up. The config packet is decoded as UTF-16 (byte order mark,
or `<` written as two bytes) or UTF-8 before the scan, as crates/xfa reads
packets. The scan walks tags, skipping comments, CDATA sections, processing
instructions and declarations, and takes the value only at
config/acrobat/acrobat7/dynamicRender, so the same name in form data, in a
comment or elsewhere in the config no longer counts.
The engine test now adds the element to the fixture's own config packet.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
---
crates/engine/src/tests.rs | 7 +-
crates/render/src/inspect.rs | 177 ++++++++++++++++++++++++++++-------
2 files changed, 146 insertions(+), 38 deletions(-)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index d7f521106c..fd4751802d 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -3940,10 +3940,11 @@ fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() {
/// and write them as a revision, so even a save with no change replaced the pages.
#[test]
fn an_xfa_form_without_fields_that_forbids_dynamic_rendering_keeps_its_pages() {
- let config = r#"forbidden "#;
+ // The fixture's own config packet gains `acrobat/acrobat7/dynamicRender`.
let template = pdfcraft_xfa::fixtures::template(1);
- let with_config = template.replacen("", forbidden, 1);
+ assert_ne!(with_config, template, "the fixture has a config packet");
// The fixture shell without /NeedsRendering (blanked in place, so the offsets stay right).
let shell = |xdp: &str| {
let bytes = pdfcraft_xfa::fixtures::shell(xdp);
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 5893d38ee0..a7b75d72f4 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -756,7 +756,7 @@ impl<'a> Inspector<'a> {
} else {
stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec()
};
- xml_element_text(&bytes, b"dynamicRender")
+ xml_path_text(&xfa_packet_text(&bytes), &["config", "acrobat", "acrobat7", "dynamicRender"])
}
fn resolve(&self, o: &'a Object) -> &'a Object {
@@ -1644,31 +1644,104 @@ pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -
catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p))))
}
-/// The trimmed text of the first `` element in `xml` (any namespace prefix), if it is plain
-/// text. A byte scan, not an XML parser: enough for one known element in an XFA packet, whose
-/// tags Designer writes with a line break before each `>`.
-fn xml_element_text(xml: &[u8], name: &[u8]) -> Option {
- let mut from = 0;
- while let Some(lt) = xml.get(from..)?.iter().position(|&b| b == b'<').map(|i| i + from) {
- from = lt + 1;
- let tag = xml.get(from..)?;
- // Skip a namespace prefix (`xfa:name`), then require the name followed by `>` or a space
- // (a self-closing ` ` holds no text).
- let local = tag
- .iter()
- .position(|&b| b == b':' || b == b'>' || b == b'/' || b.is_ascii_whitespace())
- .map_or(tag, |i| if tag.get(i) == Some(&b':') { tag.get(i + 1..).unwrap_or_default() } else { tag });
- let Some(after) = local.strip_prefix(name) else { continue };
- if !matches!(after.first(), Some(b) if *b == b'>' || b.is_ascii_whitespace()) {
+/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by
+/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one
+/// element is looked for.
+fn xfa_packet_text(bytes: &[u8]) -> String {
+ let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| {
+ let units = b.as_chunks::<2>().0.iter().map(|c| from(*c));
+ char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::()
+ };
+ match bytes {
+ [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes),
+ [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes),
+ [b'<', 0, ..] => utf16(bytes, u16::from_le_bytes),
+ [0, b'<', ..] => utf16(bytes, u16::from_be_bytes),
+ [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
+ _ => String::from_utf8_lossy(bytes).into_owned(),
+ }
+}
+
+/// One markup tag found by [`next_tag`]: where it starts (`<`) and ends (after `>`), whether it
+/// closes (``) or closes itself (` `), and its name without a namespace prefix.
+struct Tag<'a> {
+ start: usize,
+ end: usize,
+ close: bool,
+ empty: bool,
+ local: &'a str,
+}
+
+/// The next element tag in `xml` at or after `from`, skipping comments, CDATA sections,
+/// processing instructions and declarations. A byte scan, not an XML parser: enough to find one
+/// known element in an XFA packet, whose tags Designer writes with a line break before each `>`.
+fn next_tag(xml: &str, mut from: usize) -> Option> {
+ loop {
+ let lt = from + xml.get(from..)?.find('<')?;
+ let rest = xml.get(lt..)?;
+ let skip_to = |end: &str| rest.find(end).map(|i| lt + i + end.len());
+ if rest.starts_with("")?;
continue;
}
- let close = after.iter().position(|&b| b == b'>')?;
- let body = after.get(close + 1..)?;
- let end = body.iter().position(|&b| b == b'<')?;
- let text = std::str::from_utf8(body.get(..end)?).ok()?.trim();
- return (!text.is_empty()).then(|| text.to_owned());
+ if rest.starts_with("")?;
+ continue;
+ }
+ if rest.starts_with("") || rest.starts_with("")?;
+ continue;
+ }
+ let gt = lt + rest.find('>')?;
+ let inner = xml.get(lt + 1..gt)?;
+ let (close, inner) = match inner.strip_prefix('/') {
+ Some(i) => (true, i),
+ None => (false, inner),
+ };
+ let empty = !close && inner.ends_with('/');
+ let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default();
+ let local = name.rsplit(':').next().unwrap_or(name);
+ return Some(Tag { start: lt, end: gt + 1, close, empty, local });
}
- None
+}
+
+/// What `… ` holds: the content of the first element called `name` (any prefix) in
+/// `xml`. `None` when there is none, it closes itself, or it is never closed.
+fn xml_element_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
+ let mut at = 0;
+ let open = loop {
+ let tag = next_tag(xml, at)?;
+ at = tag.end;
+ if !tag.close && tag.local == name {
+ if tag.empty {
+ return None;
+ }
+ break tag.end;
+ }
+ };
+ let mut depth = 0_usize;
+ loop {
+ let tag = next_tag(xml, at)?;
+ at = tag.end;
+ if tag.local == name {
+ if tag.close {
+ if depth == 0 {
+ return xml.get(open..tag.start);
+ }
+ depth -= 1;
+ } else if !tag.empty {
+ depth = depth.saturating_add(1);
+ }
+ }
+ }
+}
+
+/// The trimmed text at `path` (element names from the outside in, any prefix) in `xml`, if it is
+/// plain text: `config/acrobat/acrobat7/dynamicRender` in an XFA configuration packet.
+fn xml_path_text(xml: &str, path: &[&str]) -> Option {
+ let body = path.iter().try_fold(xml, |within, name| xml_element_body(within, name))?;
+ let text = body.trim();
+ (!text.is_empty() && !text.contains('<')).then(|| text.to_owned())
}
#[cfg(test)]
@@ -2674,21 +2747,55 @@ trailer << /Root 1 0 R >>
}
#[test]
- fn xml_element_text_reads_one_element_as_designer_writes_it() {
- let text = |xml: &str| xml_element_text(xml.as_bytes(), b"dynamicRender");
- assert_eq!(text("forbidden ").as_deref(), Some("forbidden"));
- assert_eq!(text(" required ").as_deref(), Some("required"));
- // A longer name, a self-closing or empty element, and no element at all give nothing.
- assert_eq!(text("forbidden "), None);
- assert_eq!(text(" "), None);
- assert_eq!(text(" "), None);
- assert_eq!(text(" "), None);
+ fn xfa_dynamic_render_is_read_from_config_acrobat7_only() {
+ const PATH: &[&str] = &["config", "acrobat", "acrobat7", "dynamicRender"];
+ let text = |xml: &str| xml_path_text(xml, PATH);
+ let config =
+ |inner: &str| format!("{inner} ");
+ // Designer's style: a line break before each `>`.
+ assert_eq!(
+ text("forbidden ").as_deref(),
+ Some("forbidden")
+ );
+ assert_eq!(text(&config(" required ")).as_deref(), Some("required"));
+ // Commented out, in CDATA, or a longer name: not the element.
+ assert_eq!(text(&config("")), None);
+ assert_eq!(text(&config(" b forbidden ]]>")), None);
+ assert_eq!(text(&config("forbidden ")), None);
+ // Self-closing, empty, or holding markup: no value.
+ assert_eq!(text(&config(" ")), None);
+ assert_eq!(text(&config(" ")), None);
+ assert_eq!(text(&config("forbidden ")), None);
+ // The same name anywhere but config/acrobat/acrobat7 (form data, another config branch).
+ let xdp = "forbidden forbidden ";
+ assert_eq!(text(xdp), None);
+ // A whole XDP with the config after the template.
+ let xdp = format!(" {} ", config("forbidden "));
+ assert_eq!(text(&xdp).as_deref(), Some("forbidden"));
// Cut short anywhere: no panic, nothing read.
- let whole = "forbidden ";
+ let whole = config("forbidden ");
for end in 0..whole.len() {
- let _ = text(&whole[..end]);
+ assert_eq!(text(&whole[..end]), None, "cut at {end}");
}
- assert_eq!(text("forbidden"), None);
+ assert_eq!(text(&whole).as_deref(), Some("forbidden"));
+ }
+
+ #[test]
+ fn xfa_config_packets_are_read_in_utf16_too() {
+ let xml = "forbidden ";
+ let le: Vec = xml.encode_utf16().flat_map(u16::to_le_bytes).collect();
+ let be: Vec = xml.encode_utf16().flat_map(u16::to_be_bytes).collect();
+ for bytes in [
+ le.clone(),
+ be.clone(),
+ [&[0xFF, 0xFE][..], &le].concat(),
+ [&[0xFE, 0xFF][..], &be].concat(),
+ [&[0xEF, 0xBB, 0xBF][..], xml.as_bytes()].concat(),
+ ] {
+ assert_eq!(xfa_packet_text(&bytes), xml);
+ }
+ // An odd byte or a broken surrogate doesn't stop the scan.
+ assert!(xfa_packet_text(&[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'x']).starts_with('<'));
}
/// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were
From bf542a767699359eb2ad430ae2ecb55ea94c09c9 Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:43:30 +0300
Subject: [PATCH 07/45] M12: keep roadmap status blockquotes valid
---
docs/roadmap.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 817def7591..6bc2916c90 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,7 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
-
+>
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
From 607354ffd23897123aa65e3c382760eda4b3512b Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:43:55 +0300
Subject: [PATCH 08/45] M12: keep roadmap status blockquotes valid
---
docs/roadmap.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index a6cbccaa50..9343ae7e07 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,7 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2572: repeated XFA print buttons dispatch to their shared click script; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
-
+>
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
From 1946bcdab6fda2082083951e77508da50a60a490 Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:44:19 +0300
Subject: [PATCH 09/45] M12: keep roadmap status blockquotes valid
---
docs/roadmap.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8a5b68731a..40556659b2 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,7 +1,7 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2570: dynamic XFA rich-text character limits; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
-
+>
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
From 4899072089adf8090e097358c97f7562549693b3 Mon Sep 17 00:00:00 2001
From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com>
Date: Sun, 18 Oct 2026 22:14:02 +0800
Subject: [PATCH 10/45] fix(forms): read dynamicRender only from the
configuration packet itself (#3180)
Review follow-up: in a whole-XDP stream, a config/acrobat/acrobat7/
dynamicRender path inside xfa:datasets/xfa:data came before the real
config packet and decided the form type. The lookup now follows direct
children only: the config child of the xdp root (or the config packet
itself), then acrobat, acrobat7 and dynamicRender.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
---
crates/render/src/inspect.rs | 74 +++++++++++++++++++++++-------------
1 file changed, 47 insertions(+), 27 deletions(-)
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index a7b75d72f4..536308d741 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -756,7 +756,7 @@ impl<'a> Inspector<'a> {
} else {
stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec()
};
- xml_path_text(&xfa_packet_text(&bytes), &["config", "acrobat", "acrobat7", "dynamicRender"])
+ xfa_config_dynamic_render(&xfa_packet_text(&bytes))
}
fn resolve(&self, o: &'a Object) -> &'a Object {
@@ -1705,45 +1705,51 @@ fn next_tag(xml: &str, mut from: usize) -> Option> {
}
}
-/// What `… ` holds: the content of the first element called `name` (any prefix) in
-/// `xml`. `None` when there is none, it closes itself, or it is never closed.
-fn xml_element_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
+/// What `… ` holds: the content of the first top-level element of `xml` called `name`
+/// (any prefix). Elements nested in other elements are not looked at. `None` when there is none,
+/// it closes itself, or it is never closed.
+fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let mut at = 0;
- let open = loop {
- let tag = next_tag(xml, at)?;
- at = tag.end;
- if !tag.close && tag.local == name {
- if tag.empty {
- return None;
- }
- break tag.end;
- }
- };
let mut depth = 0_usize;
+ // Where the wanted element's content starts, once its opening tag is seen at the top level.
+ let mut open = None;
loop {
let tag = next_tag(xml, at)?;
at = tag.end;
- if tag.local == name {
- if tag.close {
- if depth == 0 {
- return xml.get(open..tag.start);
- }
- depth -= 1;
- } else if !tag.empty {
- depth = depth.saturating_add(1);
+ if tag.close {
+ depth = depth.checked_sub(1)?;
+ if depth == 0
+ && let Some(start) = open
+ {
+ return xml.get(start..tag.start);
}
+ } else if !tag.empty {
+ if depth == 0 && tag.local == name {
+ open = Some(tag.end);
+ }
+ depth = depth.saturating_add(1);
+ } else if depth == 0 && tag.local == name {
+ return None;
}
}
}
-/// The trimmed text at `path` (element names from the outside in, any prefix) in `xml`, if it is
-/// plain text: `config/acrobat/acrobat7/dynamicRender` in an XFA configuration packet.
+/// The trimmed text at `path` (element names from the outside in, any prefix, each a child of the
+/// one before) in `xml`, if it is plain text.
fn xml_path_text(xml: &str, path: &[&str]) -> Option {
- let body = path.iter().try_fold(xml, |within, name| xml_element_body(within, name))?;
+ let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?;
let text = body.trim();
(!text.is_empty() && !text.contains('<')).then(|| text.to_owned())
}
+/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child
+/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside
+/// the form data or the template are not the configuration.
+fn xfa_config_dynamic_render(packet: &str) -> Option {
+ const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"];
+ xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG))
+}
+
#[cfg(test)]
mod tests {
#[test]
@@ -2748,8 +2754,7 @@ trailer << /Root 1 0 R >>
#[test]
fn xfa_dynamic_render_is_read_from_config_acrobat7_only() {
- const PATH: &[&str] = &["config", "acrobat", "acrobat7", "dynamicRender"];
- let text = |xml: &str| xml_path_text(xml, PATH);
+ let text = xfa_config_dynamic_render;
let config =
|inner: &str| format!("{inner} ");
// Designer's style: a line break before each `>`.
@@ -2772,6 +2777,21 @@ trailer << /Root 1 0 R >>
// A whole XDP with the config after the template.
let xdp = format!(" {} ", config("forbidden "));
assert_eq!(text(&xdp).as_deref(), Some("forbidden"));
+ // A whole config path inside the form data, before the real config packet, is data: the
+ // real packet's value counts (review on #3192).
+ let xdp = format!(
+ "{} {} ",
+ config("forbidden "),
+ config("required ")
+ );
+ assert_eq!(text(&xdp).as_deref(), Some("required"));
+ // Only in the data, or nested in the template: no configuration at all.
+ let xdp = format!("{} ", config("forbidden "));
+ assert_eq!(text(&xdp), None);
+ let xdp = format!("{} ", config("forbidden "));
+ assert_eq!(text(&xdp), None);
+ // acrobat7 must be a child of acrobat, not deeper.
+ assert_eq!(text("forbidden "), None);
// Cut short anywhere: no panic, nothing read.
let whole = config("forbidden ");
for end in 0..whole.len() {
From d743760624173820374ec3546a443d5d372be7b5 Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:14:18 +0300
Subject: [PATCH 11/45] M12: reconcile current accessibility and XFA roadmap
status
---
docs/roadmap.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 6bc2916c90..584cf81742 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -136,9 +136,8 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Done since: Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Growable multiline XFA text reflows after edits (#2569), with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
From 02fe96c528ccec5b65b197b4b806a6447386b7e2 Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:14:42 +0300
Subject: [PATCH 12/45] M12: reconcile current accessibility and XFA roadmap
status
---
docs/roadmap.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 9343ae7e07..24fbe57fa9 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -136,9 +136,8 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons (#2572). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons (#2572). Done since: Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons (#2572). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
From 6425df5038f77180e6c0db25010fee2c14b97bff Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:15:06 +0300
Subject: [PATCH 13/45] M12: reconcile current accessibility and XFA roadmap
status
---
docs/roadmap.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 40556659b2..fc31ed5e77 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -136,9 +136,8 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: Dynamic XFA rich-text fields carry `exData/maxLength` into the AcroForm for reported and enforced character limits (#2570). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Dynamic XFA rich-text fields carry `exData/maxLength` into the AcroForm for reported and enforced character limits (#2570). Done since: Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Done since: Dynamic XFA rich-text fields carry `exData/maxLength` into the AcroForm for reported and enforced character limits (#2570). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
From 024407992db7d3d6edf4b3770b8c44c470252edf Mon Sep 17 00:00:00 2001
From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com>
Date: Sun, 18 Oct 2026 22:56:57 +0800
Subject: [PATCH 14/45] style: cargo fmt
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
---
crates/render/src/inspect.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 536308d741..5d0de3d7db 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -2786,7 +2786,8 @@ trailer << /Root 1 0 R >>
);
assert_eq!(text(&xdp).as_deref(), Some("required"));
// Only in the data, or nested in the template: no configuration at all.
- let xdp = format!("{} ", config("forbidden "));
+ let xdp =
+ format!("{} ", config("forbidden "));
assert_eq!(text(&xdp), None);
let xdp = format!("{} ", config("forbidden "));
assert_eq!(text(&xdp), None);
From 4119359c7dae7575c6fc75b2708c0bad01725c5c Mon Sep 17 00:00:00 2001
From: PerchAnton <101743951+PerchAnton@users.noreply.github.com>
Date: Sun, 18 Oct 2026 19:54:41 +0300
Subject: [PATCH 15/45] M12: retain accepted whitespace when structured XFA
data cannot sync
---
ROADMAP.md | 4 ++--
crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++
crates/xfa/src/data.rs | 4 +++-
crates/xfa/src/tests.rs | 17 +++++++++++++++++
docs/roadmap.md | 4 ++--
5 files changed, 49 insertions(+), 5 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index fb2a8fb217..c9907ab6e1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -38,7 +38,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
-> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -170,7 +170,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
-- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Estimates unchanged.
+- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 5aa0d70ee4..9a609ac9a9 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4492,6 +4492,31 @@ fn xfa_text_that_cannot_sync_to_structured_data_is_not_replaced_by_a_relayout()
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
}
+#[test]
+fn xfa_structured_data_whitespace_edits_survive_without_stale_relayout() {
+ let template = pdfcraft_xfa::fixtures::growable_text_template(false)
+ .replace("Short ", "Short ");
+ for text in ["Short\n", "\nShort", " Short ", "\tShort\t"] {
+ let mut s = Session::new();
+ let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap();
+ let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone();
+ let before = field(&s);
+ s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.into()) }).unwrap();
+ assert_eq!(field(&s).value, vec![text], "the accepted whitespace must not be replaced from stale datasets");
+ assert_eq!(field(&s).obj, before.obj, "a skipped data write must not rebuild the widgets");
+ let doc = s.get(id).unwrap();
+ assert!(doc.dirty);
+ assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.xfa_warnings);
+ let cos = pdfcraft_cos::Document::open(doc.bytes.clone()).unwrap();
+ let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp;
+ assert!(xdp.contains("Short "));
+ s.undo(id).unwrap();
+ assert_eq!(field(&s).value, before.value);
+ s.redo(id).unwrap();
+ assert_eq!(field(&s).value, vec![text]);
+ }
+}
+
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs
index d3e1f2c1ae..769b6f3e68 100644
--- a/crates/xfa/src/data.rs
+++ b/crates/xfa/src/data.rs
@@ -938,7 +938,9 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
if node.children().any(|c| c.is_element()) {
let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
- if all.trim() != w.text.trim() {
+ // Ignore existing XML indentation, but not whitespace in the accepted value.
+ // A skipped mismatch must warn: relayout would otherwise use stale data.
+ if all.trim() != w.text {
skipped(warnings, STRUCTURED, &w.som);
}
continue;
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 03eb2c67ad..3acb7b3031 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -577,6 +577,23 @@ fn writing_values_keeps_the_data_no_field_binds_to() {
assert!(String::from_utf8(xfa_bytes(&doc)).unwrap().contains("rich
"));
}
+#[test]
+fn structured_data_whitespace_changes_are_reported_without_flattening() {
+ let data = "\nShort
\n ";
+ for value in ["Short", "Short\n", "\nShort", " Short ", "\tShort\t"] {
+ let mut doc = Document::open(Arc::new(static_shell(data))).unwrap();
+ let before = xfa_bytes(&doc);
+ let result = write_datasets(&mut doc, &static_fields(value, false)).unwrap();
+ assert!(!result.written, "{value:?}: {result:?}");
+ assert_eq!(xfa_bytes(&doc), before, "structured XML must stay intact");
+ if value == "Short" {
+ assert!(result.warnings.is_empty(), "existing XML indentation is not a field edit: {result:?}");
+ } else {
+ assert!(result.warnings.iter().any(|w| w.contains("structured content")), "{value:?}: {result:?}");
+ }
+ }
+}
+
#[test]
fn utf16_packets_are_read_and_written_back_as_utf16() {
let mut doc = Document::open(Arc::new(static_shell("Zoë 日本 ünbound "))).unwrap();
diff --git a/docs/roadmap.md b/docs/roadmap.md
index f258c51508..00da0774e9 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -43,7 +43,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
-> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Estimates unchanged)
+> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind
it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md),
@@ -144,7 +144,7 @@ total matches the parity estimate.
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
-| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. (#2569). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). |
+| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool for one page; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. (#2569). Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
From a4cdbb6235cc3128b4228e23fe66bf0d1e299c50 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Tue, 13 Oct 2026 03:39:10 +0200
Subject: [PATCH 16/45] XFA: decode BMP and TIFF pictures (fixes #1644)
Pictures in templates and data that are BMP or TIFF were left out with a
warning. They are now recognised by their signature, sized from their
headers, and decoded through the same bounded reader as GIF (first page
of a TIFF). A BMP needs a valid info-header size after "BM", so text
starting with those two letters is still not taken for one.
A BMP or TIFF chosen for an XFA image field is kept in the data as
image/bmp or image/tiff, since the layout can now show it.
---
crates/engine/src/lib.rs | 4 ++-
crates/engine/src/tests.rs | 5 +++
crates/xfa/Cargo.toml | 2 +-
crates/xfa/src/fixtures.rs | 56 ++++++++++++++++++++++++++++++++++
crates/xfa/src/image.rs | 59 ++++++++++++++++++++++++++----------
crates/xfa/src/layout.rs | 2 +-
crates/xfa/src/pdf.rs | 8 ++---
crates/xfa/src/tests.rs | 54 +++++++++++++++++++++++++++++++--
parity/acrobat-features.toml | 6 ++--
9 files changed, 168 insertions(+), 28 deletions(-)
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index bf689af541..03e94bd0a3 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -2457,7 +2457,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
&& pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon));
if image_field {
let Some(content_type) = image_content_type(image) else {
- return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG or GIF picture; choose one of those")));
+ return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those")));
};
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone());
@@ -3167,6 +3167,8 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"),
pdfcraft_xfa::image::Kind::Png => Some("image/png"),
pdfcraft_xfa::image::Kind::Gif => Some("image/gif"),
+ pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"),
+ pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"),
}
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index d5ffac5001..d53803560e 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4295,6 +4295,11 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() {
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\""));
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo");
// Only pictures the form can show; only image fields.
+ for (data, declared) in [(pdfcraft_xfa::fixtures::tiny_bmp(false), "image/bmp"), (pdfcraft_xfa::fixtures::tiny_tiff(false), "image/tiff")] {
+ s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(data) }).unwrap();
+ let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
+ assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains(&format!("xfa:contentType=\"{declared}\"")));
+ }
assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err());
assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err());
// One datasets stream per edit, however many steps it took.
diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml
index 29032d8930..dcec1e75bc 100644
--- a/crates/xfa/Cargo.toml
+++ b/crates/xfa/Cargo.toml
@@ -17,5 +17,5 @@ roxmltree = "0.20"
base64 = "0.22"
png = "0.18"
sha2 = "0.11"
-image = { version = "0.25", default-features = false, features = ["gif"] }
+image = { version = "0.25", default-features = false, features = ["bmp", "gif", "tiff"] }
thiserror = { workspace = true }
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index 54bb9bc216..4343486653 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -267,6 +267,62 @@ const TINY_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x
/// A 1 × 1 white GIF.
pub const TINY_GIF: &[u8] = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
+/// A 2 × 1 BMP, red then blue: 24-bit, or 8-bit with a two-colour palette.
+pub fn tiny_bmp(palette: bool) -> Vec {
+ let (bpp, colours, pixels): (u16, &[u8], &[u8]) =
+ if palette { (8, &[0, 0, 255, 0, 255, 0, 0, 0], &[0, 1, 0, 0]) } else { (24, &[], &[0, 0, 255, 255, 0, 0, 0, 0]) };
+ let offset = 54 + colours.len() as u32;
+ let mut b = b"BM".to_vec();
+ b.extend_from_slice(&(offset + pixels.len() as u32).to_le_bytes());
+ b.extend_from_slice(&[0; 4]);
+ b.extend_from_slice(&offset.to_le_bytes());
+ for v in [40u32, 2, 1] {
+ b.extend_from_slice(&v.to_le_bytes());
+ }
+ b.extend_from_slice(&1u16.to_le_bytes());
+ b.extend_from_slice(&bpp.to_le_bytes());
+ for v in [0u32, pixels.len() as u32, 0, 0, (colours.len() / 4) as u32, 0] {
+ b.extend_from_slice(&v.to_le_bytes());
+ }
+ b.extend_from_slice(colours);
+ b.extend_from_slice(pixels);
+ b
+}
+
+/// A 1 × 1 little-endian RGB TIFF of colour `#123456`, uncompressed or LZW.
+pub fn tiny_tiff(lzw: bool) -> Vec {
+ // Clear, 0x12, 0x34, 0x56, end of information, as 9-bit codes, most significant bit first.
+ let strip: &[u8] = if lzw { &[0x80, 0x04, 0x86, 0x85, 0x68, 0x08] } else { &[0x12, 0x34, 0x56] };
+ // Header (8), the IFD (2 + 9 entries of 12 + 4), then BitsPerSample's three values (6).
+ let (bps_at, strip_at) = (122u32, 128u32);
+ let (short, long) = (3u16, 4u16);
+ let mut b = b"II*\0".to_vec();
+ b.extend_from_slice(&8u32.to_le_bytes());
+ b.extend_from_slice(&9u16.to_le_bytes());
+ for (tag, ty, count, value) in [
+ (256u16, short, 1u32, 1u32),
+ (257, short, 1, 1),
+ (258, short, 3, bps_at),
+ (259, short, 1, if lzw { 5 } else { 1 }),
+ (262, short, 1, 2),
+ (273, long, 1, strip_at),
+ (277, short, 1, 3),
+ (278, short, 1, 1),
+ (279, long, 1, strip.len() as u32),
+ ] {
+ b.extend_from_slice(&tag.to_le_bytes());
+ b.extend_from_slice(&ty.to_le_bytes());
+ b.extend_from_slice(&count.to_le_bytes());
+ b.extend_from_slice(&value.to_le_bytes());
+ }
+ b.extend_from_slice(&0u32.to_le_bytes());
+ for _ in 0..3 {
+ b.extend_from_slice(&8u16.to_le_bytes());
+ }
+ b.extend_from_slice(strip);
+ b
+}
+
/// A form with the other field kinds: `country`, a drop-down whose shown names save as codes
/// (`Canada`/`CA`, `France`/`FR`, `Japan`/`JP`; default `FR`); `langs`, a multi-select list
/// box (`English`, `French`, `Spanish`); `other`, an editable drop-down (`A`, `B`); `pin`, a
diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs
index 24ae77f559..7af9c0cb3a 100644
--- a/crates/xfa/src/image.rs
+++ b/crates/xfa/src/image.rs
@@ -1,5 +1,5 @@
-//! Pictures in templates and data: JPEG (embedded as is), PNG and GIF (decoded to 8-bit
-//! samples with a soft mask for transparency). Sizes are read from the headers without
+//! Pictures in templates and data: JPEG (embedded as is), PNG, GIF, BMP and TIFF (decoded to
+//! 8-bit samples with a soft mask for transparency). Sizes are read from the headers without
//! decoding, so layout stays cheap; decoding happens once, when the PDF is written.
/// Most pixels an image may have once decoded: decoding keeps up to 8 bytes per pixel alive
@@ -13,6 +13,8 @@ pub enum Kind {
Jpeg,
Png,
Gif,
+ Bmp,
+ Tiff,
}
/// What the bytes are, by their signature (the declared content type is often wrong).
@@ -23,11 +25,22 @@ pub fn kind(data: &[u8]) -> Option {
Some(Kind::Png)
} else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
Some(Kind::Gif)
+ } else if is_bmp(data) {
+ Some(Kind::Bmp)
+ } else if data.starts_with(b"II*\0") || data.starts_with(b"MM\0*") {
+ Some(Kind::Tiff)
} else {
None
}
}
+/// "BM" alone is two letters of text: the info header that follows must have one of the sizes
+/// the BMP versions define.
+fn is_bmp(data: &[u8]) -> bool {
+ data.starts_with(b"BM")
+ && data.get(14..18).is_some_and(|b| matches!(u32::from_le_bytes([b[0], b[1], b[2], b[3]]), 12 | 16 | 40 | 52 | 56 | 64 | 108 | 124))
+}
+
/// The pixel size, from the header alone; `None` when it can't be read or the image is bigger
/// than can be decoded (so layout gives such a picture its box, not a page-high strip).
pub fn size(data: &[u8]) -> Option<(u32, u32)> {
@@ -56,9 +69,25 @@ fn header_size(data: &[u8]) -> Option<(u32, u32)> {
let (w, h) = (le(6)?, le(8)?);
(w > 0 && h > 0).then_some((w, h))
}
+ // The decoders read only the headers (TIFF: the first IFD) until asked for pixels.
+ Kind::Bmp => reader(data, image::ImageFormat::Bmp).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0),
+ Kind::Tiff => reader(data, image::ImageFormat::Tiff).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0),
}
}
+/// A decoder told the limits, so a frame larger than its header claims is refused before it is
+/// allocated.
+fn reader(data: &[u8], format: image::ImageFormat) -> image::ImageReader> {
+ let mut reader = image::ImageReader::new(std::io::Cursor::new(data));
+ reader.set_format(format);
+ let mut limits = image::Limits::default();
+ limits.max_image_width = Some(MAX_SIDE);
+ limits.max_image_height = Some(MAX_SIDE);
+ limits.max_alloc = Some(MAX_PIXELS * 4);
+ reader.limits(limits);
+ reader
+}
+
/// Samples ready for an image XObject.
#[derive(Debug)]
pub struct Decoded {
@@ -76,8 +105,8 @@ fn too_big() -> String {
format!("an image has more than {} million pixels and is left out", MAX_PIXELS / 1_000_000)
}
-/// Decode a PNG or GIF (its first frame). JPEGs are embedded as they are and are not decoded
-/// here. The error is the warning to show.
+/// Decode a PNG, GIF (its first frame), BMP or TIFF (its first page). JPEGs are embedded as they
+/// are and are not decoded here. The error is the warning to show.
pub fn decode(data: &[u8]) -> Result {
let bad = |m: String| format!("an image could not be decoded and is left out: {m}");
let (w, h) = header_size(data).ok_or_else(|| bad("unreadable header".into()))?;
@@ -117,17 +146,15 @@ pub fn decode(data: &[u8]) -> Result {
let alpha = (has_alpha && alpha.iter().any(|a| *a != 255)).then_some(alpha);
Ok(Decoded { width: frame.width, height: frame.height, color_space, samples: colour, alpha })
}
- Some(Kind::Gif) => {
- // The frame may be larger than the logical screen the header gives: the decoder is
- // told the limits, so it refuses before allocating.
- let mut reader = image::ImageReader::new(std::io::Cursor::new(data));
- reader.set_format(image::ImageFormat::Gif);
- let mut limits = image::Limits::default();
- limits.max_image_width = Some(MAX_SIDE);
- limits.max_image_height = Some(MAX_SIDE);
- limits.max_alloc = Some(MAX_PIXELS * 4);
- reader.limits(limits);
- let img = reader.decode().map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?;
+ Some(k @ (Kind::Gif | Kind::Bmp | Kind::Tiff)) => {
+ // A GIF's frame may be larger than the logical screen the header gives.
+ let format = match k {
+ Kind::Gif => image::ImageFormat::Gif,
+ Kind::Bmp => image::ImageFormat::Bmp,
+ _ => image::ImageFormat::Tiff,
+ };
+ let img =
+ reader(data, format).decode().map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?;
let rgba = img.into_rgba8();
let (width, height) = rgba.dimensions();
if !fits(width, height) {
@@ -139,6 +166,6 @@ pub fn decode(data: &[u8]) -> Result {
let alpha = px.iter().any(|p| p[3] != 255).then(|| px.iter().map(|p| p[3]).collect());
Ok(Decoded { width, height, color_space: if gray { "DeviceGray" } else { "DeviceRGB" }, samples, alpha })
}
- Some(Kind::Jpeg) | None => Err(bad("not a PNG or GIF".into())),
+ Some(Kind::Jpeg) | None => Err(bad("not a PNG, GIF, BMP or TIFF".into())),
}
}
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 62d7beedf1..e6e5f58020 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1529,7 +1529,7 @@ impl Layouter<'_> {
match base64::engine::general_purpose::STANDARD.decode(&text) {
Ok(d) if crate::image::kind(&d).is_some() => Some(d),
Ok(_) => {
- self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG or GIF and is left out"));
+ self.warn_once(format!("{field_name}: the picture in the data is not a JPEG, PNG, GIF, BMP or TIFF and is left out"));
None
}
Err(_) => {
diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs
index cca62e64e7..9069e5965e 100644
--- a/crates/xfa/src/pdf.rs
+++ b/crates/xfa/src/pdf.rs
@@ -491,8 +491,8 @@ impl Emitter<'_> {
Ok(Some(r))
}
- /// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs and GIFs decoded
- /// to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning,
+ /// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs, GIFs, BMPs and TIFFs
+ /// decoded to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning,
/// for anything else.
fn image_xobject(&mut self, data: &[u8], content_type: &str) -> Option {
let hash = content_hash(data);
@@ -539,7 +539,7 @@ impl Emitter<'_> {
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
Some(self.doc.add(Object::Stream(Stream::from_raw(d, data.to_vec()))))
}
- Some(crate::image::Kind::Png | crate::image::Kind::Gif) => {
+ Some(crate::image::Kind::Png | crate::image::Kind::Gif | crate::image::Kind::Bmp | crate::image::Kind::Tiff) => {
let img = match crate::image::decode(data) {
Ok(img) => img,
Err(why) => {
@@ -564,7 +564,7 @@ impl Emitter<'_> {
Some(self.doc.add(Object::Stream(Stream::flate(d, &img.samples))))
}
None => {
- self.warnings.push(format!("a {what} image is not a JPEG, PNG or GIF and is left out"));
+ self.warnings.push(format!("a {what} image is not a JPEG, PNG, GIF, BMP or TIFF and is left out"));
None
}
}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 03eb2c67ad..cfae8f42fe 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -7,7 +7,9 @@ use crate::data::{
DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, write_datasets,
};
use crate::data::{add_data_instances, remove_data_instance, write_data_value};
-use crate::fixtures::{TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png};
+use crate::fixtures::{
+ TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_bmp, tiny_png, tiny_tiff,
+};
use crate::layout::Item;
use crate::layout::WidgetKind;
use crate::model::*;
@@ -1094,7 +1096,7 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte
assert!(!root.as_dict().unwrap().contains(b"PCXfaImages"));
// Pictures in the data that aren't pictures: a warning naming the field, the template's
// picture shown instead.
- for (data, what) in [("Qk0AAAAA", "not a JPEG, PNG or GIF"), ("not base64!", "not base64")] {
+ for (data, what) in [("Qk0AAAAA", "not a JPEG, PNG, GIF, BMP or TIFF"), ("not base64!", "not base64")] {
let tpl = fields_template(&format!(""));
let form = layout_xml(&tpl).unwrap();
assert!(form.warnings.iter().any(|w| w.contains("photo") && w.contains(what)), "{what}: {:?}", form.warnings);
@@ -1141,6 +1143,54 @@ fn pictures_are_embedded_once_across_relayouts_and_bad_data_pictures_are_reporte
assert!(find_image(&t.root.children).is_some_and(|n| n > 1_200_000));
}
+/// Designer forms often carry BMP and TIFF pictures, declared as `image/bmp` and `image/tif`
+/// (the Government of Canada's IMM 5257 and IMM 5710 have both). They were left out.
+#[test]
+fn bmp_and_tiff_pictures_are_decoded_and_drawn() {
+ use crate::image::{Kind, decode, kind, size};
+ for palette in [false, true] {
+ let bmp = tiny_bmp(palette);
+ assert_eq!(kind(&bmp), Some(Kind::Bmp));
+ assert_eq!(size(&bmp), Some((2, 1)));
+ let d = decode(&bmp).unwrap();
+ assert_eq!((d.width, d.height, d.color_space), (2, 1, "DeviceRGB"), "palette: {palette}");
+ assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255], "red then blue, palette: {palette}");
+ assert_eq!(d.alpha, None);
+ }
+ for lzw in [false, true] {
+ let tiff = tiny_tiff(lzw);
+ assert_eq!(kind(&tiff), Some(Kind::Tiff));
+ assert_eq!(size(&tiff), Some((1, 1)));
+ let d = decode(&tiff).unwrap();
+ assert_eq!((d.width, d.height, d.color_space), (1, 1, "DeviceRGB"), "lzw: {lzw}");
+ assert_eq!(d.samples, vec![0x12, 0x34, 0x56], "lzw: {lzw}");
+ }
+ assert_eq!(kind(b"MM\0*\0\0\0\x08"), Some(Kind::Tiff), "big-endian");
+ let mut cut = tiny_tiff(true);
+ cut.truncate(60);
+ assert!(decode(&cut).is_err());
+ // In a form: drawn, with no warning, whatever the declared type says.
+ use base64::Engine;
+ for (data, declared) in
+ [(tiny_bmp(false), "image/bmp"), (tiny_bmp(true), "image/x-ms-bmp"), (tiny_tiff(false), "image/tif"), (tiny_tiff(true), "image/tiff")]
+ {
+ let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
+ let gif = base64::engine::general_purpose::STANDARD.encode(TINY_GIF);
+ let xml =
+ fields_template("").replace(&format!(r#"{gif}"#), &format!(r#"{b64}"#));
+ assert!(xml.contains(declared));
+ let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
+ let report = render_into(&mut doc).unwrap();
+ assert!(!report.warnings.iter().any(|w| w.contains("left out")), "{declared}: {:?}", report.warnings);
+ let samples: Vec = if declared.contains("bmp") { vec![255, 0, 0, 0, 0, 255] } else { vec![0x12, 0x34, 0x56] };
+ let drawn = doc.object_numbers().into_iter().any(|n| {
+ matches!(&*doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }), Object::Stream(st)
+ if st.dict.name(b"Subtype") == Some(b"Image") && st.decoded().ok().as_ref() == Some(&samples))
+ });
+ assert!(drawn, "{declared}: its pixels are an image XObject");
+ }
+}
+
/// A repeating subform drawn inside positioned content (a top-to-bottom table in an area of a
/// positioned subform) gets its `occur` instances, as in Adobe's viewers: real Designer forms
/// have tables like this whose calculations read every row. Before, only one row was laid
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index d8fbe77ddf..348274ea9d 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5220,8 +5220,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"]
-notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
+evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"]
+notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"
@@ -5245,7 +5245,7 @@ status = "partial"
commands = ["file.open", "file.save", "form.export_data", "form.import_data"]
tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"]
evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"]
-notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
+notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF, BMP, TIFF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
[[feature]]
id = "form.xfa-flatten"
From 36cfc8566ea004d7a42e2c9ce9930b62a6863860 Mon Sep 17 00:00:00 2001
From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com>
Date: Mon, 19 Oct 2026 01:53:12 +0800
Subject: [PATCH 17/45] M10: dynamicRender is read only from markup whose
closing tags match (#3180)
Review follow-up on #3192.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
---
crates/render/src/inspect.rs | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 5d0de3d7db..12c7204caf 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -1710,25 +1710,29 @@ fn next_tag(xml: &str, mut from: usize) -> Option> {
/// it closes itself, or it is never closed.
fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let mut at = 0;
- let mut depth = 0_usize;
+ // The elements open around `at`, innermost last: a closing tag must name the innermost one,
+ // or the markup is malformed and has no value.
+ let mut open_tags: Vec<&str> = Vec::new();
// Where the wanted element's content starts, once its opening tag is seen at the top level.
let mut open = None;
loop {
let tag = next_tag(xml, at)?;
at = tag.end;
if tag.close {
- depth = depth.checked_sub(1)?;
- if depth == 0
+ if open_tags.pop()? != tag.local {
+ return None;
+ }
+ if open_tags.is_empty()
&& let Some(start) = open
{
return xml.get(start..tag.start);
}
} else if !tag.empty {
- if depth == 0 && tag.local == name {
+ if open_tags.is_empty() && tag.local == name {
open = Some(tag.end);
}
- depth = depth.saturating_add(1);
- } else if depth == 0 && tag.local == name {
+ open_tags.push(tag.local);
+ } else if open_tags.is_empty() && tag.local == name {
return None;
}
}
@@ -2771,6 +2775,9 @@ trailer << /Root 1 0 R >>
assert_eq!(text(&config(" ")), None);
assert_eq!(text(&config(" ")), None);
assert_eq!(text(&config("forbidden ")), None);
+ // Closing tags that don't match what they close: malformed, no value (review on #3192).
+ assert_eq!(text(&config("forbidden")), None);
+ assert_eq!(text("forbidden "), None);
// The same name anywhere but config/acrobat/acrobat7 (form data, another config branch).
let xdp = "forbidden forbidden ";
assert_eq!(text(xdp), None);
From 947f0f2994b08c3d62797ce9ee5077eac0d2956a Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Sun, 18 Oct 2026 20:37:33 +0200
Subject: [PATCH 18/45] XFA: turn TIFF pictures by their Orientation tag
---
crates/xfa/src/image.rs | 27 ++++++++++++++++++++++++---
crates/xfa/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+), 3 deletions(-)
diff --git a/crates/xfa/src/image.rs b/crates/xfa/src/image.rs
index 7af9c0cb3a..5ec42873ed 100644
--- a/crates/xfa/src/image.rs
+++ b/crates/xfa/src/image.rs
@@ -71,10 +71,31 @@ fn header_size(data: &[u8]) -> Option<(u32, u32)> {
}
// The decoders read only the headers (TIFF: the first IFD) until asked for pixels.
Kind::Bmp => reader(data, image::ImageFormat::Bmp).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0),
- Kind::Tiff => reader(data, image::ImageFormat::Tiff).into_dimensions().ok().filter(|&(w, h)| w > 0 && h > 0),
+ Kind::Tiff => {
+ use image::{ImageDecoder, metadata::Orientation};
+ let mut decoder = reader(data, image::ImageFormat::Tiff).into_decoder().ok()?;
+ let (w, h) = decoder.dimensions();
+ // Shown turned a quarter: the size viewers show is the stored one swapped.
+ let turned = matches!(
+ decoder.orientation().ok()?,
+ Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH
+ );
+ let (w, h) = if turned { (h, w) } else { (w, h) };
+ (w > 0 && h > 0).then_some((w, h))
+ }
}
}
+/// A TIFF's first page as viewers show it: turned and flipped as its Orientation tag says.
+fn decode_tiff(data: &[u8]) -> image::ImageResult {
+ use image::ImageDecoder;
+ let mut decoder = reader(data, image::ImageFormat::Tiff).into_decoder()?;
+ let orientation = decoder.orientation()?;
+ let mut img = image::DynamicImage::from_decoder(decoder)?;
+ img.apply_orientation(orientation);
+ Ok(img)
+}
+
/// A decoder told the limits, so a frame larger than its header claims is refused before it is
/// allocated.
fn reader(data: &[u8], format: image::ImageFormat) -> image::ImageReader> {
@@ -153,8 +174,8 @@ pub fn decode(data: &[u8]) -> Result {
Kind::Bmp => image::ImageFormat::Bmp,
_ => image::ImageFormat::Tiff,
};
- let img =
- reader(data, format).decode().map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?;
+ let img = if k == Kind::Tiff { decode_tiff(data) } else { reader(data, format).decode() }
+ .map_err(|e| if matches!(e, image::ImageError::Limits(_)) { too_big() } else { bad(e.to_string()) })?;
let rgba = img.into_rgba8();
let (width, height) = rgba.dimensions();
if !fits(width, height) {
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index cfae8f42fe..3e4a16e9bf 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -2117,3 +2117,40 @@ fn som_indices_count_earlier_same_named_siblings() {
let sibs = SomIndices::of(&children);
assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]);
}
+
+/// A TIFF is drawn as viewers show it, turned by its Orientation tag: a 2 × 1 picture (red,
+/// blue) tagged 6 (turn 90° clockwise) is 1 × 2, red above blue, in layout and in the PDF.
+#[test]
+fn a_tiff_picture_is_turned_by_its_orientation_tag() {
+ let strip: &[u8] = &[255, 0, 0, 0, 0, 255];
+ let (bps_at, strip_at) = (8 + 2 + 10 * 12 + 4, 8 + 2 + 10 * 12 + 4 + 6);
+ let mut b = b"II*\0".to_vec();
+ b.extend_from_slice(&8u32.to_le_bytes());
+ b.extend_from_slice(&10u16.to_le_bytes());
+ for (tag, ty, count, value) in [
+ (256u16, 3u16, 1u32, 2u32),
+ (257, 3, 1, 1),
+ (258, 3, 3, bps_at as u32),
+ (259, 3, 1, 1),
+ (262, 3, 1, 2),
+ (273, 4, 1, strip_at as u32),
+ (274, 3, 1, 6),
+ (277, 3, 1, 3),
+ (278, 3, 1, 1),
+ (279, 4, 1, strip.len() as u32),
+ ] {
+ b.extend_from_slice(&tag.to_le_bytes());
+ b.extend_from_slice(&ty.to_le_bytes());
+ b.extend_from_slice(&count.to_le_bytes());
+ b.extend_from_slice(&value.to_le_bytes());
+ }
+ b.extend_from_slice(&0u32.to_le_bytes());
+ for _ in 0..3 {
+ b.extend_from_slice(&8u16.to_le_bytes());
+ }
+ b.extend_from_slice(strip);
+ let d = crate::image::decode(&b).unwrap();
+ assert_eq!(crate::image::size(&b), Some((1, 2)), "layout size");
+ assert_eq!((d.width, d.height), (1, 2), "decoded size");
+ assert_eq!(d.samples, vec![255, 0, 0, 0, 0, 255]);
+}
From 93f90ef760fae73555ff6d41352c18ea545cb9ee Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Sun, 18 Oct 2026 23:45:26 -0400
Subject: [PATCH 19/45] M12: XFA dates are written and read in the field's data
picture
A field's data picture (, such as date{YYYYMMDD}) is how
its value is written in the XFA data, but PdfCraft always wrote dates
there as ISO (2026-10-18) and read them back as ISO, so a form that keeps
its dates as 20261018 got a different format from PdfCraft than from
other viewers, and data those viewers wrote was shown as stored.
The parser keeps as Field::data_picture. A typed date is
still read in the field's own pattern and turned into ISO; it is then
written in the data picture when the field has one. Reading goes the
other way, on layout and when values are taken from the data of a form
laid out before. A value that doesn't match its data picture is read as
ISO, so data written by earlier versions still reads. The date-letters
rule for XFA picture clauses moves to one helper (date_letters) that the
layout's date_pattern now uses too.
---
crates/engine/src/tests.rs | 22 +++++++++++
crates/xfa/src/data.rs | 74 ++++++++++++++++++++++++++++++++++--
crates/xfa/src/fixtures.rs | 26 +++++++++++++
crates/xfa/src/layout.rs | 18 ++++-----
crates/xfa/src/model.rs | 3 ++
crates/xfa/src/parse.rs | 1 +
crates/xfa/src/tests.rs | 16 +++++++-
parity/acrobat-features.toml | 4 +-
8 files changed, 147 insertions(+), 17 deletions(-)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index f125c41dac..c25427fc1a 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4184,6 +4184,28 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten");
}
+/// A date goes into the XFA data in the field's data picture (``), as other
+/// viewers write and read it, and comes back from there when the form is opened again. It used
+/// to be written as an ISO date whatever the picture said.
+#[test]
+fn xfa_dates_are_written_and_read_back_in_their_data_picture() {
+ let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::bound_date_template("")));
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("form.pdf", None, bytes, None).expect("opens");
+ s.apply(id, Edit::SetFieldValue { name: "due".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap();
+ s.apply(id, Edit::SetFieldValue { name: "when".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap();
+ let saved = s.save_bytes(id).unwrap();
+ let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
+ let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap();
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].due[0]")), Some("20261018"), "in the field's data picture");
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].when[0]")), Some("2026-10-18"), "ISO without one");
+ let id2 = s.open("again.pdf", None, saved, None).unwrap();
+ let d2 = s.get(id2).unwrap();
+ let value = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone();
+ assert_eq!(value("due"), vec!["10/18/2026".to_string()]);
+ assert_eq!(value("when"), vec!["10/18/2026".to_string()]);
+}
+
#[test]
fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
// Regression: editing a field replaced the whole data element with the fields' values.
diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs
index d3e1f2c1ae..30084d9601 100644
--- a/crates/xfa/src/data.rs
+++ b/crates/xfa/src/data.rs
@@ -12,7 +12,7 @@ use std::collections::HashMap;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use crate::XfaError;
-use crate::model::{Node, Template};
+use crate::model::{Field, Node, Template};
use crate::packets::{Encoding, encode};
const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/";
@@ -157,6 +157,67 @@ fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> {
})
}
+/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as
+/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond
+/// years, months, days and separators).
+pub(crate) fn date_letters(picture: &str) -> Option {
+ let inner = picture
+ .find('{')
+ .and_then(|a| {
+ let rest = picture.get(a + 1..)?;
+ rest.find('}').and_then(|b| rest.get(..b))
+ })
+ .unwrap_or(picture)
+ .trim();
+ if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) {
+ return None;
+ }
+ Some(inner.replace('E', "d").to_ascii_lowercase())
+}
+
+/// A date as the data holds it, written in `data_pattern` (the field's data picture), read as
+/// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it
+/// is: the data holds ISO dates by default.
+pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String {
+ data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string())
+}
+
+/// An ISO date as the data should hold it: in `data_pattern` when the field has one.
+fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String {
+ match data_pattern {
+ Some(p) => iso_to_pattern(&iso, p),
+ None => iso,
+ }
+}
+
+/// The template field a SOM path names (instance numbers aside), looking through unnamed
+/// subforms and areas as [`data_path`] does; `None` when the template has no such field.
+fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> {
+ let path = som_to_path(som);
+ let ((root, _), rest) = path.split_first()?;
+ if tpl.root.common.name.as_deref() != Some(root.as_str()) {
+ return None;
+ }
+ let ((last, _), inner) = rest.split_last()?;
+ let mut children: &[Node] = &tpl.root.children;
+ for (name, _) in inner {
+ children = match named(children, name, 0)? {
+ Node::Subform(sf) => &sf.children,
+ Node::Area(a) => &a.children,
+ _ => return None,
+ };
+ }
+ match named(children, last, 0)? {
+ Node::Field(f) => Some(f),
+ _ => None,
+ }
+}
+
+/// The date letters of the data picture of the field `som` names, when it has one.
+fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option {
+ tpl.and_then(|t| template_field(t, som)).and_then(|f| f.data_picture.as_deref()).and_then(date_letters)
+}
+
/// What a field holds, for writing the datasets.
#[derive(Clone, Debug, PartialEq)]
pub enum FieldData {
@@ -456,7 +517,11 @@ fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec<
}
let text = match &f.data {
FieldData::Text(t) => match date_pattern_of(doc, f) {
- Some(p) => pattern_to_iso(t, &p).unwrap_or_else(|| t.clone()),
+ Some(p) => match pattern_to_iso(t, &p) {
+ // The date goes into the data in the field's data picture, or as an ISO date.
+ Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()),
+ None => t.clone(),
+ },
None => t.clone(),
},
FieldData::Check(on) => {
@@ -1530,12 +1595,13 @@ fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> V
let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() };
let mut out = Vec::new();
for f in fields {
- let path = path_of(tpl, &som_of(doc, f));
+ let som = som_of(doc, f);
+ let path = path_of(tpl, &som);
let Some(text) = data.value_at(&path) else { continue };
let text = text.as_str();
let value = match &f.data {
FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) {
- Some(pat) => iso_to_pattern(text, &pat),
+ Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat),
None => text.to_string(),
}),
FieldData::Check(_) => {
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index 54bb9bc216..e368a4fa46 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -257,6 +257,32 @@ pub fn leave_template(data: &str) -> String {
)
}
+/// Two date fields shown as `MM/DD/YYYY`: `due` binds its value to the data as `YYYYMMDD`
+/// (``), `when` has no data picture, so its data holds ISO dates.
+pub fn bound_date_template(data: &str) -> String {
+ let datasets = if data.is_empty() {
+ String::new()
+ } else {
+ format!("{data} ")
+ };
+ format!(
+ r##"
+
+
+
+
+
+ date{{MM/DD/YYYY}} date{{YYYYMMDD}}
+ date{{MM/DD/YYYY}}
+
+
+
+{datasets}
+
+"##
+ )
+}
+
/// A 2 × 2 RGBA PNG: red, green / blue, transparent.
pub fn tiny_png() -> Vec {
TINY_PNG.to_vec()
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 62d7beedf1..1b24e8c8d8 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -13,7 +13,7 @@ use std::sync::Arc;
use base64::Engine;
use crate::XfaError;
-use crate::data::{DataNode, data_path, is_on, iso_to_pattern};
+use crate::data::{DataNode, data_path, data_to_iso, date_letters, is_on, iso_to_pattern};
use crate::model::*;
use crate::text::{self, Block, Face, Span};
@@ -396,13 +396,7 @@ fn is_row(node: &Node) -> bool {
/// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`.
fn date_pattern(picture: Option<&str>) -> String {
- let p = picture.unwrap_or("");
- let inner = p.find('{').and_then(|a| p[a + 1..].find('}').map(|b| &p[a + 1..a + 1 + b])).unwrap_or(p);
- let inner = inner.trim();
- if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) {
- return "yyyy-mm-dd".into();
- }
- inner.replace('E', "d").to_ascii_lowercase()
+ picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into())
}
fn button_action(scripts: &[Script]) -> Option {
@@ -1642,8 +1636,12 @@ impl Layouter<'_> {
}
Ui::DateTimeEdit => {
let pattern = date_pattern(f.picture.as_deref());
- // Data holds ISO dates; the field shows them in its pattern.
- let shown = plain_value.as_ref().map(|v| if data_value.is_some() { iso_to_pattern(v, &pattern) } else { v.clone() });
+ // The data holds the date in the field's data picture, or else as an ISO date; the
+ // field shows it in its own pattern.
+ let data_pattern = f.data_picture.as_deref().and_then(date_letters);
+ let shown = plain_value
+ .as_ref()
+ .map(|v| if data_value.is_some() { iso_to_pattern(&data_to_iso(v, data_pattern.as_deref()), &pattern) } else { v.clone() });
(WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone())
}
Ui::ChoiceList => {
diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs
index 572675f958..48878bdc6b 100644
--- a/crates/xfa/src/model.rs
+++ b/crates/xfa/src/model.rs
@@ -493,6 +493,9 @@ pub struct Field {
pub access: Access,
/// The edit picture clause (`…`), e.g. `date{YYYY-MM-DD}`.
pub picture: Option,
+ /// The data picture clause (`…`), e.g. `date{YYYYMMDD}`: how the value is
+ /// written in the data. Without one, dates there are ISO.
+ pub data_picture: Option,
/// `
+
+ 1 0 0
+
+
+
+
+
+"##
+ .to_string()
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index b1805d5223..88b456ecf5 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5220,8 +5220,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"]
-notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
+evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error"]
+notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"
From 162a891673134cc6c1c323e6a1e3deec76d99bc1 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 01:29:36 -0400
Subject: [PATCH 23/45] M12: XFA text honors its horizontal font scale
fontHorizontalScale on a font, and xfa-font-horizontal-scale in rich text,
condense the glyphs; PdfCraft ignored both, so condensed labels were laid
out at full width and overflowed or wrapped where the form fits them on
one line. Text is now measured at its scale and written with Tz, in a
graphics state of its own. Field values keep full width: the forms layer
draws them, and its appearances have no horizontal scaling.
---
crates/xfa/src/layout.rs | 6 ++--
crates/xfa/src/model.rs | 9 +++++-
crates/xfa/src/parse.rs | 21 ++++++++++++-
crates/xfa/src/pdf.rs | 10 ++++--
crates/xfa/src/tests.rs | 66 ++++++++++++++++++++++++++++++++++++++++
crates/xfa/src/text.rs | 16 ++++++++--
6 files changed, 119 insertions(+), 9 deletions(-)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 62d7beedf1..72ab40fc66 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1078,7 +1078,7 @@ impl Layouter<'_> {
};
let w = (inner_w - beside - f.ui_margin.horizontal() - 4.0).max(1.0);
let font = ctx.font.apply(&f.common.font);
- text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font), &|_| String::new())
+ text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font).unscaled(), &|_| String::new())
}
fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
@@ -1589,8 +1589,8 @@ impl Layouter<'_> {
}
let face = match (&f.ui, &f.caption) {
// Buttons show their caption in the caption's font.
- (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)),
- _ => Face::of(&fctx.font),
+ (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(),
+ _ => Face::of(&fctx.font).unscaled(),
};
let h_align = match (&f.ui, &f.caption) {
(Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align,
diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs
index 572675f958..8ea85ccbcc 100644
--- a/crates/xfa/src/model.rs
+++ b/crates/xfa/src/model.rs
@@ -155,6 +155,8 @@ pub struct FontSpec {
pub italic: Option,
pub underline: Option,
pub color: Option,
+ /// `fontHorizontalScale`, as a fraction (0.9 for `90%`).
+ pub h_scale: Option,
}
/// A font with every property resolved.
@@ -166,12 +168,14 @@ pub struct Font {
pub italic: bool,
pub underline: bool,
pub color: Color,
+ /// Horizontal scale of the glyphs, as a fraction (1.0: as designed).
+ pub h_scale: f64,
}
impl Default for Font {
/// XFA's default font: Courier 10 pt.
fn default() -> Self {
- Font { typeface: "Courier".into(), size: 10.0, bold: false, italic: false, underline: false, color: Color::BLACK }
+ Font { typeface: "Courier".into(), size: 10.0, bold: false, italic: false, underline: false, color: Color::BLACK, h_scale: 1.0 }
}
}
@@ -184,6 +188,7 @@ impl Font {
italic: spec.italic.unwrap_or(self.italic),
underline: spec.underline.unwrap_or(self.underline),
color: spec.color.unwrap_or(self.color),
+ h_scale: spec.h_scale.unwrap_or(self.h_scale),
}
}
}
@@ -611,6 +616,8 @@ pub struct Run {
pub italic: Option,
pub underline: Option,
pub size: Option,
+ /// `xfa-font-horizontal-scale`, as a fraction.
+ pub h_scale: Option,
/// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value.
pub embed: Option,
}
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index 4582d59b22..2996b4ffbc 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -44,6 +44,13 @@ fn attr_measure(n: XmlNode, name: &str) -> Option {
n.attribute(name).and_then(measure)
}
+/// A horizontal scale such as `90%` (a bare number is a percentage too), as a fraction, within
+/// 1% and 1000%; `None` when it isn't a positive number.
+fn scale(v: &str) -> Option {
+ let v: f64 = v.trim().trim_end_matches('%').trim().parse().ok()?;
+ (v.is_finite() && v > 0.0).then(|| (v / 100.0).clamp(0.01, 10.0))
+}
+
fn child<'a, 'd>(n: XmlNode<'a, 'd>, name: &str) -> Option> {
n.children().find(|c| c.is_element() && c.tag_name().name() == name)
}
@@ -148,6 +155,7 @@ fn parse_font(n: Option) -> FontSpec {
italic: n.attribute("posture").map(|p| p == "italic"),
underline: n.attribute("underline").map(|u| u != "0"),
color: parse_color(child(n, "fill").and_then(|f| child(f, "color"))),
+ h_scale: n.attribute("fontHorizontalScale").and_then(scale),
}
}
@@ -196,6 +204,7 @@ fn parse_rich(body: XmlNode) -> RichText {
italic: Option,
underline: Option,
size: Option,
+ h_scale: Option,
}
fn styled(base: &Style, css: Option<&str>) -> Style {
let mut s = base.clone();
@@ -207,6 +216,7 @@ fn parse_rich(body: XmlNode) -> RichText {
"font-style" => s.italic = Some(v == "italic" || v == "oblique"),
"text-decoration" => s.underline = Some(v.contains("underline")),
"font-size" => s.size = measure(v).filter(|x| *x > 0.0),
+ "xfa-font-horizontal-scale" => s.h_scale = scale(v),
_ => {}
}
}
@@ -232,6 +242,7 @@ fn parse_rich(body: XmlNode) -> RichText {
italic: style.italic,
underline: style.underline,
size: style.size,
+ h_scale: style.h_scale,
embed: None,
},
);
@@ -271,7 +282,15 @@ fn parse_rich(body: XmlNode) -> RichText {
push_run(
out,
explicit,
- Run { text: String::new(), bold: st.bold, italic: st.italic, underline: st.underline, size: st.size, embed: Some(e) },
+ Run {
+ text: String::new(),
+ bold: st.bold,
+ italic: st.italic,
+ underline: st.underline,
+ size: st.size,
+ h_scale: st.h_scale,
+ embed: Some(e),
+ },
);
}
walk(c, &st, out, explicit, depth + 1);
diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs
index cca62e64e7..593c78a80d 100644
--- a/crates/xfa/src/pdf.rs
+++ b/crates/xfa/src/pdf.rs
@@ -612,11 +612,17 @@ impl Emitter<'_> {
}
let name = s.face.resource_name();
fonts.insert(name, s.face.family.base_font(s.face.bold, s.face.italic));
+ // Horizontal scaling is text state, which outlives the text object: a
+ // condensed span gets a graphics state of its own, so the next isn't.
+ let scaled = (s.face.h_scale - 1.0).abs() > 1e-6;
+ let (open, tz, close) =
+ if scaled { ("q BT", format!(" {} Tz", n(s.face.h_scale * 100.0)), " ET Q") } else { ("BT", String::new(), " ET") };
content.extend(
- format!("BT /{name} {} Tf {} rg 1 0 0 1 {} {} Tm ", n(s.face.size), rgb(s.face.color), n(s.x), n(h - s.baseline)).bytes(),
+ format!("{open} /{name} {} Tf {} rg{tz} 1 0 0 1 {} {} Tm ", n(s.face.size), rgb(s.face.color), n(s.x), n(h - s.baseline))
+ .bytes(),
);
content.extend(literal(&win_ansi(&s.text)));
- content.extend_from_slice(b" Tj ET\n");
+ content.extend_from_slice(format!(" Tj{close}\n").as_bytes());
}
Item::Image { rect, data, content_type } => {
let Some(r) = self.image_xobject(data, content_type) else { continue };
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 03eb2c67ad..215b1cf3a3 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -33,6 +33,72 @@ fn measurements_convert_to_points() {
assert_eq!(measure("NaNpt"), None);
}
+/// Two draws of the same text, one at `fontHorizontalScale="80%"`, each 90% as wide as the
+/// text is at full width, and a rich-text draw condensed to half by its span style.
+fn scaled_text_template() -> (String, &'static str) {
+ const TEXT: &str = "Approving authority name and title";
+ let w = pdfcraft_fonts::helvetica_width(TEXT, 10.0) * 0.9;
+ let xdp = format!(
+ r##"
+
+
+ {TEXT}
+ {TEXT}
+ Full half
+
+ "##
+ );
+ (xdp, TEXT)
+}
+
+#[test]
+fn horizontally_scaled_text_is_measured_and_drawn_narrower() {
+ let (xdp, text) = scaled_text_template();
+ let (tpl, _) = parse(&xdp).unwrap();
+ let Node::Draw(narrow) = &tpl.root.children[0] else { panic!() };
+ assert_eq!(narrow.common.font.h_scale, Some(0.8), "fontHorizontalScale is a percentage");
+ let form = layout_xml(&xdp).unwrap();
+ let spans: Vec<&text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect();
+ let lines = |scale: f64, words: &[&str]| {
+ let mut b: Vec = spans
+ .iter()
+ .filter(|s| s.face.h_scale == scale && words.iter().any(|w| s.text.contains(w)))
+ .map(|s| (s.baseline * 100.0).round() as i64)
+ .collect();
+ b.dedup();
+ b.len()
+ };
+ let words: Vec<&str> = text.split(' ').collect();
+ // At 80% the text is 72% of its full width: it fits the 90% box on one line.
+ assert_eq!(lines(0.8, &words), 1, "{spans:?}");
+ // At full width it doesn't: two lines.
+ assert_eq!(lines(1.0, &["Approving", "title"]), 2, "{spans:?}");
+ // A span's own scale applies to it alone.
+ let half = spans.iter().find(|s| s.text.contains("half")).expect("the half span");
+ let full = spans.iter().find(|s| s.text.contains("Full")).expect("the full span");
+ assert_eq!((half.face.h_scale, full.face.h_scale), (0.5, 1.0));
+ assert!((half.face.width("half") - full.face.width("half") / 2.0).abs() < 1e-9);
+}
+
+#[test]
+fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() {
+ let (xdp, _) = scaled_text_template();
+ let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
+ render_into(&mut doc).unwrap();
+ let root = doc.get(doc.root().unwrap());
+ let pages = doc.resolve(root.as_dict().unwrap().get(b"Pages").unwrap());
+ let kid = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].clone();
+ let page = doc.resolve(&kid);
+ let contents = doc.resolve(page.as_dict().unwrap().get(b"Contents").unwrap());
+ let Object::Stream(c) = &*contents else { panic!("a content stream") };
+ let ops = String::from_utf8_lossy(&c.decoded().unwrap()).into_owned();
+ let scaled: Vec<&str> = ops.lines().filter(|l| l.contains(" Tz ")).collect();
+ assert!(!scaled.is_empty() && scaled.iter().all(|l| l.starts_with("q BT ") && l.ends_with(" ET Q")), "{ops}");
+ assert!(scaled.iter().any(|l| l.contains(" 80 Tz ")) && scaled.iter().any(|l| l.contains(" 50 Tz ")), "{ops}");
+ // Full-width text sets no scale, and none is left over from the condensed text before it.
+ assert!(ops.lines().filter(|l| l.contains(" Tj ") && !l.contains(" Tz ")).all(|l| l.starts_with("BT ")), "{ops}");
+}
+
#[test]
fn the_template_parses_with_fonts_captions_and_rich_text() {
let (tpl, warnings) = parse(&template(2)).unwrap();
diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs
index 6db0777c06..f880a945cf 100644
--- a/crates/xfa/src/text.rs
+++ b/crates/xfa/src/text.rs
@@ -75,6 +75,9 @@ pub struct Face {
pub size: f64,
pub color: [f64; 3],
pub underline: bool,
+ /// Horizontal scale of the glyphs (`fontHorizontalScale`), as a fraction: widths are
+ /// measured at it and the text is written with `Tz`.
+ pub h_scale: f64,
}
impl Face {
@@ -86,6 +89,7 @@ impl Face {
size: font.size.clamp(1.0, 300.0),
color: font.color.0,
underline: font.underline,
+ h_scale: font.h_scale.clamp(0.01, 10.0),
}
}
@@ -97,20 +101,28 @@ impl Face {
size: run.size.unwrap_or(self.size).clamp(1.0, 300.0),
color: self.color,
underline: run.underline.unwrap_or(self.underline),
+ h_scale: run.h_scale.unwrap_or(self.h_scale).clamp(0.01, 10.0),
}
}
+ /// This face as designed, without a horizontal scale: field text is drawn by the forms
+ /// layer, whose appearances have none, so it is measured that way too.
+ pub fn unscaled(self) -> Face {
+ Face { h_scale: 1.0, ..self }
+ }
+
pub fn resource_name(&self) -> &'static str {
self.family.resource_name(self.bold, self.italic)
}
/// Advance of `s` at this face's size.
pub fn width(&self, s: &str) -> f64 {
- match self.family {
+ let designed = match self.family {
Family::Courier => s.chars().count() as f64 * 0.6 * self.size,
Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 },
Family::Times => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 0.96 } else { 0.92 },
- }
+ };
+ designed * self.h_scale
}
}
From b2f40e0442abbfd7260569e6dafae7b537a99025 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 14:14:42 -0400
Subject: [PATCH 24/45] M12: a space at the end of a line doesn't count when
fitting XFA text
Words keep the space after them, and the line-fit test counted it, so a
label written with a trailing space wrapped when its box was a fraction
of a point narrower than the label plus that space: DA Form 31's 'a.
DATE ' and 'b. TIME ' put their second word on top of the field below.
A line now fits by what it draws, and alignment ignores every trailing
space, not just one.
---
crates/xfa/src/tests.rs | 46 +++++++++++++++++++++++++++++++++++++++++
crates/xfa/src/text.rs | 25 ++++++++++++++++++----
2 files changed, 67 insertions(+), 4 deletions(-)
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 03eb2c67ad..c54028c26d 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -2067,3 +2067,49 @@ fn som_indices_count_earlier_same_named_siblings() {
let sibs = SomIndices::of(&children);
assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]);
}
+
+/// A draw `w` points wide holding `text` in Arial 10 pt, aligned `h_align`.
+fn one_draw(text: &str, w: f64, h_align: &str) -> String {
+ format!(
+ r##"
+
+
+ {text}
+
+ "##
+ )
+}
+
+/// The text spans of the only page, with their baselines.
+fn draw_spans(xdp: &str) -> Vec {
+ layout_xml(xdp).unwrap().pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.clone()) } else { None }).collect()
+}
+
+#[test]
+fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() {
+ // The box is just wide enough for "a. DATE"; the label is written "a. DATE " (as forms
+ // often are). The trailing space is never drawn, so the label stays on one line.
+ let w = pdfcraft_fonts::helvetica_width("a. DATE", 10.0) + 0.01;
+ let spans = draw_spans(&one_draw("a. DATE ", w, "left"));
+ let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
+ assert_eq!(baselines.len(), 1, "{spans:?}");
+ // Several spaces after it don't count either.
+ let spans = draw_spans(&one_draw("a. DATE ", w, "left"));
+ let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
+ assert_eq!(baselines.len(), 1, "{spans:?}");
+ // Text that is wider than the box still wraps.
+ let spans = draw_spans(&one_draw("a. DATES ", w, "left"));
+ let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
+ assert_eq!(baselines.len(), 2, "{spans:?}");
+}
+
+#[test]
+fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() {
+ let w = 200.0;
+ let ink = pdfcraft_fonts::helvetica_width("TITLE", 10.0);
+ let left_edge = |align: &str, text: &str| draw_spans(&one_draw(text, w, align)).first().map(|s| s.x).unwrap();
+ // The draw's left edge is the content area's (0.5in); centered and right-aligned text is
+ // placed by its drawn width, whatever spaces follow it.
+ assert!((left_edge("center", "TITLE ") - (36.0 + (w - ink) / 2.0)).abs() < 1e-6);
+ assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6);
+}
diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs
index 6db0777c06..77b9bdd49f 100644
--- a/crates/xfa/src/text.rs
+++ b/crates/xfa/src/text.rs
@@ -145,6 +145,13 @@ struct Word {
}
/// Break the runs of a paragraph into words (spaces stay attached to the word before them).
+/// The width of `w` without the spaces it ends with: what it takes at the end of a line, where
+/// a space is never drawn.
+fn ink_width(w: &Word) -> f64 {
+ let ink = w.text.trim_end_matches(' ');
+ if ink.len() == w.text.len() { w.width } else { w.face.width(ink) }
+}
+
fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec {
let mut out = Vec::new();
for run in runs {
@@ -197,12 +204,14 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn
let mut first_in_para = true;
let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 };
for w in ws {
- let fits = line.width + w.width <= avail - indent(line.first) || line.words.is_empty() && w.width <= avail - indent(line.first);
+ // A word fits when what it draws does: the spaces after it would end the line.
+ let ink = ink_width(&w);
+ let fits = line.width + ink <= avail - indent(line.first) || line.words.is_empty() && ink <= avail - indent(line.first);
if !fits && !line.words.is_empty() {
let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false });
lines.push((done, p.h_align));
}
- if w.width > avail - indent(line.first) && line.words.is_empty() {
+ if ink > avail - indent(line.first) && line.words.is_empty() {
// A word longer than a line: break it by character.
let mut piece = String::new();
for ch in w.text.chars() {
@@ -248,8 +257,16 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn
let mut y = start_y + para.space_above;
for ((line, align), lh) in lines.into_iter().zip(heights) {
let indent = if line.first { para.text_indent.max(0.0) } else { 0.0 };
- // Trailing spaces don't count for alignment.
- let trailing = line.words.last().map_or(0.0, |w| if w.text.ends_with(' ') { w.face.width(" ") } else { 0.0 });
+ // Trailing spaces don't count for alignment: those of the last word with ink, and any
+ // words of spaces alone after it.
+ let mut trailing = 0.0;
+ for w in line.words.iter().rev() {
+ let ink = ink_width(w);
+ trailing += w.width - ink;
+ if !w.text.trim_end_matches(' ').is_empty() {
+ break;
+ }
+ }
let used = (line.width - trailing).max(0.0);
let mut x = rect.x + para.margin_left + indent;
x += match align.unwrap_or(para.h_align) {
From dd782bca1da895f55c0f48462b6bd7d95161bb12 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 14:42:37 -0400
Subject: [PATCH 25/45] M12: a non-interactive XFA field is measured at its
text's horizontal scale
The layout draws a non-interactive field's text itself, condensed, but
measured its height at full width, so a condensed value reserved lines
it didn't use. Fields now measure and draw their text in one face:
scaled when the layout draws it, as designed when the forms layer does
(the over-page fallback included).
---
crates/xfa/src/layout.rs | 14 ++++++++++++--
crates/xfa/src/tests.rs | 31 +++++++++++++++++++++++++++++++
2 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 72ab40fc66..6de989bf4a 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1062,6 +1062,14 @@ impl Layouter<'_> {
}
}
+ /// The face a field's text is laid out in: at its horizontal scale when the layout draws
+ /// the text itself (a non-interactive field), as designed when the forms layer draws it in
+ /// a widget, whose appearances have no horizontal scaling.
+ fn field_text_face(f: &Field, font: &Font) -> Face {
+ let face = Face::of(font);
+ if f.access == Access::NonInteractive { face } else { face.unscaled() }
+ }
+
/// The height the text of a multi-line text field takes (its value in the data, else the
/// template's), for a field without a fixed height, which grows to hold its text. `ctx`
/// is the field's own (its SOM ends in the field). Zero for anything else.
@@ -1078,7 +1086,7 @@ impl Layouter<'_> {
};
let w = (inner_w - beside - f.ui_margin.horizontal() - 4.0).max(1.0);
let font = ctx.font.apply(&f.common.font);
- text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Face::of(&font).unscaled(), &|_| String::new())
+ text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_| String::new())
}
fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
@@ -1583,7 +1591,9 @@ impl Layouter<'_> {
right: f.ui_margin.right + 2.0,
};
let para = Para { v_align: VAlign::Top, ..fctx.para.clone() };
- let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &Face::of(&fctx.font), &|_| String::new());
+ // In the face it was measured in, so it takes the lines it was given.
+ let face = Self::field_text_face(f, &fctx.font);
+ let block = text::layout(&text::plain(value.trim_end()), ui.inset(&m), ¶, &face, &|_| String::new());
return self.push_block(block);
}
}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 215b1cf3a3..41a6781f43 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -80,6 +80,37 @@ fn horizontally_scaled_text_is_measured_and_drawn_narrower() {
assert!((half.face.width("half") - full.face.width("half") / 2.0).abs() < 1e-9);
}
+#[test]
+fn a_non_interactive_field_is_measured_at_the_scale_its_text_is_drawn() {
+ // A growing, non-interactive text field (drawn as text by the layout, not as a widget),
+ // then a draw below it. However condensed the field's text is, the draw sits the same
+ // distance below its last line: the field is as tall as the lines it shows.
+ // Long enough to take more than a multi-line field's three lines at either scale.
+ const TEXT: &str = "Remarks that run over several lines of a narrow box, and then over several more lines again, until the box has grown well past the three lines it starts with";
+ let gap = |scale: u32| {
+ let xdp = format!(
+ r##"
+
+
+ {TEXT}
+ AFTER
+
+ "##
+ );
+ let form = layout_xml(&xdp).unwrap();
+ let spans: Vec<&text::Span> = form.pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).collect();
+ let after = spans.iter().find(|s| s.text.contains("AFTER")).expect("the draw").baseline;
+ let lines: std::collections::BTreeSet =
+ spans.iter().filter(|s| !s.text.contains("AFTER")).map(|s| (s.baseline * 100.0).round() as i64).collect();
+ let last = *lines.iter().next_back().expect("the field's lines") as f64 / 100.0;
+ (lines.len(), after - last)
+ };
+ let (full_lines, full_gap) = gap(100);
+ let (narrow_lines, narrow_gap) = gap(60);
+ assert!(narrow_lines < full_lines, "condensed, the text takes fewer lines: {narrow_lines} vs {full_lines}");
+ assert!((narrow_gap - full_gap).abs() < 0.01, "the field shrinks with its text: gap {narrow_gap} vs {full_gap}");
+}
+
#[test]
fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() {
let (xdp, _) = scaled_text_template();
From 6dc8ad933b8c6b2b661b6f85353986c4665e61e2 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 14:53:28 -0400
Subject: [PATCH 26/45] M12: spaces alone stay on an XFA text line
A word of spaces alone draws nothing, but after a word whose own
trailing space ran past the box it failed the fit test and opened a
blank line, so the text measured a line taller than it draws. Such
words now always stay on the line, keeping their width.
---
crates/xfa/src/tests.rs | 4 ++++
crates/xfa/src/text.rs | 7 +++++--
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index c54028c26d..9784986feb 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -2097,6 +2097,10 @@ fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() {
let spans = draw_spans(&one_draw("a. DATE ", w, "left"));
let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
assert_eq!(baselines.len(), 1, "{spans:?}");
+ // Nor do they add a blank line: the text measures as tall as without them.
+ let face = text::Face::of(&Font { typeface: "Arial".into(), size: 10.0, ..Font::default() });
+ let height = |t: &str| text::measure_height(&text::plain(t), w, &Para::default(), &face, &|_| String::new());
+ assert_eq!(height("a. DATE "), height("a. DATE"));
// Text that is wider than the box still wraps.
let spans = draw_spans(&one_draw("a. DATES ", w, "left"));
let baselines: std::collections::BTreeSet = spans.iter().map(|s| (s.baseline * 100.0).round() as i64).collect();
diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs
index 77b9bdd49f..cbfd9fbc52 100644
--- a/crates/xfa/src/text.rs
+++ b/crates/xfa/src/text.rs
@@ -204,9 +204,12 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn
let mut first_in_para = true;
let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 };
for w in ws {
- // A word fits when what it draws does: the spaces after it would end the line.
+ // A word fits when what it draws does: the spaces after it would end the line. Spaces
+ // alone draw nothing, so they always stay on the line (keeping their width, so a word
+ // after them still starts past them).
let ink = ink_width(&w);
- let fits = line.width + ink <= avail - indent(line.first) || line.words.is_empty() && ink <= avail - indent(line.first);
+ let blank = w.text.trim_end_matches(' ').is_empty();
+ let fits = blank || line.width + ink <= avail - indent(line.first) || line.words.is_empty() && ink <= avail - indent(line.first);
if !fits && !line.words.is_empty() {
let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false });
lines.push((done, p.h_align));
From 32bb14b9524533736b32937bc9a488b8c04ac5ad Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:06:32 -0400
Subject: [PATCH 27/45] M12: an XFA label or button without a width is as wide
as its text
A draw or a button with no width took all the width available, so a
right-aligned label slid to the far side of its subform and a button
spread over what lay beside it: DA Form 31's Instructions button covered
its header and the email line, and its PERSONNEL/FINANCE line ran into
the field after it. They now grow to their text, unwrapped, within what
is available and never below their minimum width, as containers do.
---
crates/xfa/src/layout.rs | 22 ++++++++++++++--
crates/xfa/src/tests.rs | 57 ++++++++++++++++++++++++++++++++++++++++
crates/xfa/src/text.rs | 8 ++++++
3 files changed, 85 insertions(+), 2 deletions(-)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 62d7beedf1..f44a23478c 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -912,7 +912,8 @@ impl Layouter<'_> {
let ctx = ctx.with_som(c, 0);
let som = ctx.som.clone();
// A container without a width is as wide as its content (a radio group beside its
- // question, say); a leaf without one takes what is available.
+ // question, say), and so is a text label or a button (as wide as its caption), within
+ // what is available; any other field without one takes what is available.
let w = match c.w {
Some(w) => w,
None => match node {
@@ -920,7 +921,13 @@ impl Layouter<'_> {
let inner = (avail_w - c.margin.horizontal()).max(1.0);
(self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w)
}
- Node::Field(_) | Node::Draw(_) => avail_w,
+ Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)),
+ Node::Field(f) if f.ui == Ui::Button => f
+ .caption
+ .as_ref()
+ .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)))
+ .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)),
+ Node::Field(_) => avail_w,
},
};
let w = clamp_opt(w, c.min_w, c.max_w);
@@ -1004,6 +1011,17 @@ impl Layouter<'_> {
}
}
+ /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying
+ /// it out at that width keeps it on its lines; `None` for what isn't text.
+ fn natural_width(&self, v: &Value, font: &Font, para: &Para) -> Option {
+ let rich = match v {
+ Value::Text(t) => text::plain(t),
+ Value::Rich(r) => r.clone(),
+ _ => return None,
+ };
+ Some(text::natural_width(&rich, para, &Face::of(font), &|id| self.embed(id)) + 0.01)
+ }
+
fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 {
match v {
Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)),
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 03eb2c67ad..02bb07dfc1 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -1195,6 +1195,63 @@ fn repeating_subforms_inside_positioned_content_get_their_instances() {
/// treated like a subform (at most once), so once the first body page was out the layout fell
/// back to the last page area, here one limited to a single use, and drew its content over every
/// body page (and listed a copy of its fields per page).
+/// A positioned subform holding `body`, laid out; its page's items.
+fn positioned(body: &str) -> Vec- {
+ let xdp = format!(
+ r##"
+
+
+ {body}
+
+ "##
+ );
+ layout_xml(&xdp).unwrap().pages.into_iter().next().unwrap().items
+}
+
+#[test]
+fn a_label_without_a_width_is_as_wide_as_its_text() {
+ const TEXT: &str = "PERSONNEL ONLY: from date (YYYYMMDD):";
+ let natural = pdfcraft_fonts::helvetica_width(TEXT, 8.0);
+ // Right-aligned, with a minimum narrower than the text: the box is as wide as the text and
+ // starts where it is placed, so the text does too (not at the far right of the subform).
+ let items = positioned(&format!(
+ r#"{TEXT} "#
+ ));
+ let span = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).expect("the text");
+ assert!((span.x - (36.0 + 72.0)).abs() < 0.05, "starts at its x: {}", span.x);
+ // With a minimum wider than the text, the box is that wide and the text ends at its right.
+ let items = positioned(&format!(
+ r#"{TEXT} "#,
+ natural + 40.0
+ ));
+ let span = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s) } else { None }).expect("the text");
+ assert!((span.x + span.face.width(&span.text) - (36.0 + 72.0 + natural + 40.0)).abs() < 0.05, "ends at its right: {}", span.x);
+ // Text longer than the room left still wraps at the container's width.
+ let long = "word ".repeat(200);
+ let items = positioned(&format!(r#"{long} "#));
+ let lines: std::collections::BTreeSet =
+ items.iter().filter_map(|i| if let Item::Text(s) = i { Some((s.baseline * 100.0).round() as i64) } else { None }).collect();
+ assert!(lines.len() > 1, "wraps: {} line(s)", lines.len());
+}
+
+#[test]
+fn a_button_without_a_width_is_as_wide_as_its_caption() {
+ // No width, a minimum narrower than its caption: as wide as the caption, not the subform.
+ let caption = "Open the instructions for this form";
+ let items = positioned(&format!(
+ r#"{caption} "#
+ ));
+ let button = items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }).expect("the button");
+ let natural = pdfcraft_fonts::helvetica_width(caption, 8.0);
+ assert!(button.rect.w >= natural && button.rect.w < natural + 10.0, "{} for a caption of {natural}", button.rect.w);
+ // A minimum wider than the caption wins.
+ let items = positioned(
+ r#"Help "#,
+ );
+ let button = items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }).expect("the button");
+ assert!((button.rect.w - 200.0).abs() < 0.05, "{}", button.rect.w);
+}
+
#[test]
fn page_area_without_occur_serves_every_body_page() {
let section = |n: u32| {
diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs
index 6db0777c06..c885da8c98 100644
--- a/crates/xfa/src/text.rs
+++ b/crates/xfa/src/text.rs
@@ -280,6 +280,14 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn
Block { spans, height: total }
}
+/// The width `rich` takes with no line wrapped (its longest paragraph, with the paragraph's
+/// margins and first-line indent): how wide a box without an explicit width grows.
+pub fn natural_width(rich: &RichText, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> f64 {
+ let indent = para.text_indent.max(0.0);
+ let widest = rich.paragraphs.iter().map(|p| words(&p.runs, base, embed).iter().map(|w| w.width).sum::()).fold(0.0, f64::max);
+ (para.margin_left + indent + widest + para.margin_right).max(0.0)
+}
+
/// Height `rich` needs at `width` (for boxes without an explicit height).
pub fn measure_height(rich: &RichText, width: f64, para: &Para, base: &Face, embed: &dyn Fn(&str) -> String) -> f64 {
let p = Para { v_align: crate::model::VAlign::Top, ..para.clone() };
From da38e0da6f28571a15210dc35e39dfd8932d9bec Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:26:37 -0400
Subject: [PATCH 28/45] M12: an XFA button without a height is as tall as its
caption
A button with no height was sized like a text field in its field font,
with text-field padding, so DA Form 31's Instructions button (10 pt
field font, 8 pt caption) came out about 17 pt tall and over the form
below it. It is now as tall as its caption, never below its minimum.
---
crates/xfa/src/layout.rs | 12 ++++++++++++
crates/xfa/src/tests.rs | 14 ++++++++++++++
2 files changed, 26 insertions(+)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index f44a23478c..72b5151375 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1060,6 +1060,18 @@ impl Layouter<'_> {
let lines = if f.multiline { 3.0 } else { 1.0 };
let ui = match f.ui {
Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)),
+ // A button shows its caption: as tall as the caption's text in its own font.
+ Ui::Button if f.caption.as_ref().is_some_and(|c| !c.value.is_empty()) => {
+ let cap = f.caption.as_ref().map_or(0.0, |c| {
+ let rich = match &c.value {
+ Value::Rich(r) => r.clone(),
+ other => text::plain(&other.plain().unwrap_or_default()),
+ };
+ let w = (inner_w - f.ui_margin.horizontal()).max(1.0);
+ text::measure_height(&rich, w, &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id| self.embed(id))
+ });
+ cap + f.ui_margin.vertical()
+ }
_ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(),
};
match self.caption_reserve(f, inner_w, ctx) {
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 02bb07dfc1..e14917b7ed 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -1252,6 +1252,20 @@ fn a_button_without_a_width_is_as_wide_as_its_caption() {
assert!((button.rect.w - 200.0).abs() < 0.05, "{}", button.rect.w);
}
+#[test]
+fn a_button_without_a_height_is_as_tall_as_its_caption() {
+ // A 10pt field font, an 8pt caption: the button is as tall as its caption's line (no
+ // text-field padding), and never below its minimum height.
+ let button = |min_h: &str| {
+ let items = positioned(&format!(
+ r#"Help "#
+ ));
+ items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w.rect.h) } else { None }).expect("the button")
+ };
+ assert!((button("2pt") - text::line_height(8.0)).abs() < 0.05, "{}", button("2pt"));
+ assert!((button("10.5pt") - 10.5).abs() < 0.05, "{}", button("10.5pt"));
+}
+
#[test]
fn page_area_without_occur_serves_every_body_page() {
let section = |n: u32| {
From 37f43e947d6f1126a01453594186de4d07b571fc Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:41:00 -0400
Subject: [PATCH 29/45] M12: an XFA button's box is its whole field
A button was given its content area (inside its margins) as its widget
rectangle, though its border belongs to the field: DA Form 31's
Instructions button, with a 1.25 pt top margin, came out shorter than
the Attachments Menu button beside it. Its box is now the whole field;
the margins pad the caption.
---
crates/xfa/src/layout.rs | 6 +++---
crates/xfa/src/tests.rs | 11 +++++++++++
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 72b5151375..d1943d68fb 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1662,13 +1662,13 @@ impl Layouter<'_> {
}
Ui::Button => {
let caption = f.caption.as_ref().and_then(|c| c.value.plain()).unwrap_or_default().trim().to_string();
- // A button's box is its field border; without one it is invisible (a link
- // over text, say).
+ // A button's box is its field border, around the whole field (its margins pad
+ // the caption, not the box); without one it is invisible (a link over text, say).
let border = match &f.common.border {
Some(b) => widget_border(Some(b), false),
None => WidgetBorder { color: None, fill: None, shape: BorderShape::None, width: 0.0 },
};
- (WidgetKind::Button { caption }, ui, border, None, f.tooltip.clone())
+ (WidgetKind::Button { caption }, rect, border, None, f.tooltip.clone())
}
Ui::DateTimeEdit => {
let pattern = date_pattern(f.picture.as_deref());
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index e14917b7ed..70ec183c3a 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -1266,6 +1266,17 @@ fn a_button_without_a_height_is_as_tall_as_its_caption() {
assert!((button("10.5pt") - 10.5).abs() < 0.05, "{}", button("10.5pt"));
}
+#[test]
+fn a_buttons_box_is_the_whole_field_whatever_its_margins() {
+ // The button's box is its field's border: its margins pad the caption, not the box.
+ let items = positioned(
+ r#"Help "#,
+ );
+ let button = items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w.rect) } else { None }).expect("the button");
+ assert!((button.w - 100.0).abs() < 0.05 && (button.h - 20.0).abs() < 0.05, "{button:?}");
+ assert!((button.x - (36.0 + 72.0)).abs() < 0.05, "{button:?}");
+}
+
#[test]
fn page_area_without_occur_serves_every_body_page() {
let section = |n: u32| {
From 35e04743d0f87500fc6027a33c6e3e8c7c5c5a68 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 21:26:55 -0400
Subject: [PATCH 30/45] M12: showing an XFA subform leaves its unnamed hidden
objects hidden
---
crates/xfa/src/fixtures.rs | 24 ++++++++++++++++++++++++
crates/xfa/src/script.rs | 25 +++++++++++++++----------
crates/xfa/src/tests.rs | 24 +++++++++++++++++++++++-
3 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index 54bb9bc216..a46bee5a28 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -118,6 +118,30 @@ fn assemble(objs: Vec>) -> Vec {
/// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform;
/// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons;
/// `grand`, the calculated sum of the amounts.
+/// A hidden subform `details` a script may show, holding a named field and three unnamed
+/// objects: a caption, an old caption the form keeps hidden, and a hidden subform. Unnamed
+/// objects have no SOM name of their own, so showing `details` must leave the hidden ones hidden.
+pub fn unnamed_hidden_template() -> String {
+ r##"
+
+
+
+
+
+
+ Current caption
+ Old caption
+ Old section
+
+
+
+
+
+
+"##
+ .to_string()
+}
+
pub fn scripted_template() -> String {
r##"
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 17a0376641..df7ba2fdfb 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -227,8 +227,10 @@ impl Walker<'_> {
}
}
- fn presence(&self, som: &str, p: Presence) -> String {
- self.ov.presence.get(som).cloned().unwrap_or_else(|| presence_name(p).to_string())
+ /// The presence of the object named `name` at `som`: a script's, or else the template's. An
+ /// unnamed object shares its parent's SOM path, so a presence set there is not its own.
+ fn presence(&self, name: Option<&str>, som: &str, p: Presence) -> String {
+ name.and_then(|_| self.ov.presence.get(som)).cloned().unwrap_or_else(|| presence_name(p).to_string())
}
fn access(&self, som: &str, a: Access) -> String {
@@ -254,7 +256,7 @@ impl Walker<'_> {
kind: Some(NodeKind::Field),
value,
numeric: f.numeric,
- presence: self.presence(&som, f.common.presence),
+ presence: self.presence(Some("field"), &som, f.common.presence),
access: self.access(&som, f.access),
repeatable: false,
occur_min: 1,
@@ -307,7 +309,7 @@ impl Walker<'_> {
kind: Some(NodeKind::Subform),
value: String::new(),
numeric: false,
- presence: self.presence(&som, sf.common.presence),
+ presence: self.presence(sf.common.name.as_deref(), &som, sf.common.presence),
access: "open".into(),
repeatable: sf.occur.max != Some(1),
occur_min: sf.occur.min,
@@ -324,7 +326,7 @@ impl Walker<'_> {
name: a.common.name.clone().unwrap_or_else(|| "#area".into()),
som: som.clone(),
kind: Some(NodeKind::Area),
- presence: self.presence(&som, a.common.presence),
+ presence: self.presence(a.common.name.as_deref(), &som, a.common.presence),
access: "open".into(),
occur_min: 1,
occur_max: Some(1),
@@ -341,7 +343,7 @@ impl Walker<'_> {
som: som.clone(),
kind: Some(NodeKind::Draw),
value: d.value.plain().unwrap_or_default(),
- presence: self.presence(&som, d.common.presence),
+ presence: self.presence(d.common.name.as_deref(), &som, d.common.presence),
access: "open".into(),
occur_min: 1,
occur_max: Some(1),
@@ -368,7 +370,7 @@ impl Walker<'_> {
som: som.clone(),
kind: Some(NodeKind::ExclGroup),
value: selected.unwrap_or_default(),
- presence: self.presence(&som, g.common.presence),
+ presence: self.presence(Some("group"), &som, g.common.presence),
access: "open".into(),
occur_min: 1,
occur_max: Some(1),
@@ -455,9 +457,12 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
Node::ExclGroup(_) => Some("group"),
_ => None,
};
- let som = child_som(parent_som, n.common().name.as_deref().or(name_default), sib);
- let presence = ov.presence.get(&som).map(|p| Presence::parse(Some(p)));
- let access = ov.access.get(&som).cloned();
+ let name = n.common().name.as_deref().or(name_default);
+ let som = child_som(parent_som, name, sib);
+ // An unnamed draw, area or subform has no SOM name: `som` is its parent's, and so are
+ // the overrides under it.
+ let presence = name.and_then(|_| ov.presence.get(&som)).map(|p| Presence::parse(Some(p)));
+ let access = name.and_then(|_| ov.access.get(&som)).cloned();
match n {
Node::Subform(s) => {
if let Some(p) = presence {
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 03eb2c67ad..d1503527cc 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -7,7 +7,9 @@ use crate::data::{
DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, write_datasets,
};
use crate::data::{add_data_instances, remove_data_instance, write_data_value};
-use crate::fixtures::{TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png};
+use crate::fixtures::{
+ TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png, unnamed_hidden_template,
+};
use crate::layout::Item;
use crate::layout::WidgetKind;
use crate::model::*;
@@ -714,6 +716,26 @@ fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() {
assert!(overrides(&doc).is_empty());
}
+#[test]
+fn showing_a_subform_leaves_its_unnamed_hidden_objects_hidden() {
+ let (tpl, _) = parse(&unnamed_hidden_template()).unwrap();
+ let mut ov = Overrides::default();
+ ov.presence.insert("form[0].page1[0].details[0]".into(), "visible".into());
+ let form = layout(&apply_overrides(&tpl, &ov), None).unwrap();
+ let shown = texts(&form.pages[0]);
+ assert!(shown.iter().any(|t| t == "Current caption"), "{shown:?}");
+ assert!(!shown.iter().any(|t| t == "Old caption" || t == "Old section"), "{shown:?}");
+ assert_eq!(form.fields, 1);
+ // Scripts see the same: the hidden draw stays hidden in the live form.
+ let root = form_tree(&tpl, None, &ov).root;
+ let details = &root.children[0].children[0];
+ assert_eq!(details.presence, "visible");
+ let presence = |text: &str| details.children.iter().find(|c| c.value == text).map(|c| c.presence.clone());
+ assert_eq!(presence("Current caption").as_deref(), Some("visible"));
+ assert_eq!(presence("Old caption").as_deref(), Some("hidden"));
+ assert_eq!(details.children.iter().find(|c| c.kind == Some(NodeKind::Subform)).map(|c| c.presence.as_str()), Some("hidden"));
+}
+
#[test]
fn data_instances_are_added_and_removed_in_the_packet() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
From 15ab8c7a2c2ce1e84ef446ea668e8e778549a0d9 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:42:22 -0400
Subject: [PATCH 31/45] M12: doc comments for the functions this change touches
---
crates/xfa/src/layout.rs | 3 +++
crates/xfa/src/model.rs | 2 ++
crates/xfa/src/parse.rs | 7 +++++++
crates/xfa/src/pdf.rs | 3 +++
crates/xfa/src/tests.rs | 7 +++++++
crates/xfa/src/text.rs | 6 ++++++
6 files changed, 28 insertions(+)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 6de989bf4a..feb8a6fde3 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1464,6 +1464,9 @@ impl Layouter<'_> {
if *n == 1 { base } else { format!("{base}_{n}") }
}
+ /// Draw a field in `rect`: its border and caption, then its value as text (non-interactive
+ /// fields, barcodes, text longer than a page) or as a widget. `radio` puts it in an
+ /// exclusion group; `sib` is its index in its SOM path (`name[sib]`).
fn draw_field(
&mut self,
f: &Field,
diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs
index 8ea85ccbcc..1c456a916e 100644
--- a/crates/xfa/src/model.rs
+++ b/crates/xfa/src/model.rs
@@ -180,6 +180,8 @@ impl Default for Font {
}
impl Font {
+ /// This font with the properties `spec` sets in place of its own; those `spec` leaves
+ /// `None` are inherited.
pub fn apply(&self, spec: &FontSpec) -> Font {
Font {
typeface: spec.typeface.clone().unwrap_or_else(|| self.typeface.clone()),
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index 2996b4ffbc..3ebdc92c73 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -146,6 +146,8 @@ fn parse_border(n: Option) -> Option {
Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) })
}
+/// ``: typeface, size, weight, posture, underline, fill color and horizontal scale.
+/// What the element doesn't set, or a missing element, is left `None` to inherit.
fn parse_font(n: Option) -> FontSpec {
let Some(n) = n else { return FontSpec::default() };
FontSpec {
@@ -206,6 +208,8 @@ fn parse_rich(body: XmlNode) -> RichText {
size: Option,
h_scale: Option,
}
+ /// `base` with the CSS declarations in `css` applied: weight, style, underline, size and
+ /// `xfa-font-horizontal-scale`. Anything else is ignored.
fn styled(base: &Style, css: Option<&str>) -> Style {
let mut s = base.clone();
for decl in css.unwrap_or("").split(';') {
@@ -224,6 +228,9 @@ fn parse_rich(body: XmlNode) -> RichText {
}
// Paragraph boundaries from `p` are implicit (dropped when empty); `br` makes an explicit
// line break that stays even when the line is blank.
+ /// Add the text under `n` to `out` as runs in `style`, as the elements it meets restyle it;
+ /// `p` and `br` start paragraphs (`explicit` marks the `br` ones) and `embed` spans add
+ /// embedded fields. Nothing deeper than `MAX_DEPTH` is read.
fn walk(n: XmlNode, style: &Style, out: &mut RichText, explicit: &mut Vec, depth: usize) {
if depth > MAX_DEPTH {
return;
diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs
index 593c78a80d..00d473f0da 100644
--- a/crates/xfa/src/pdf.rs
+++ b/crates/xfa/src/pdf.rs
@@ -570,6 +570,9 @@ impl Emitter<'_> {
}
}
+ /// Write one laid-out page under `pages_ref`: its fills, lines, text and pictures in a
+ /// content stream, the fonts and images it uses as resources, and its widgets as
+ /// annotations. Returns the page object.
fn page(&mut self, page: &Page, pages_ref: ObjRef) -> Result {
let page_ref = self.doc.add(Object::Dict(Dict::new()));
let mut content: Vec = Vec::new();
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 41a6781f43..3d01245abd 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -51,6 +51,9 @@ fn scaled_text_template() -> (String, &'static str) {
(xdp, TEXT)
}
+/// `fontHorizontalScale` is read as a percentage and text is measured at it: the 80% draw
+/// fits its box on one line where the full-width one takes two, and a span's own scale
+/// applies to that span alone.
#[test]
fn horizontally_scaled_text_is_measured_and_drawn_narrower() {
let (xdp, text) = scaled_text_template();
@@ -80,6 +83,8 @@ fn horizontally_scaled_text_is_measured_and_drawn_narrower() {
assert!((half.face.width("half") - full.face.width("half") / 2.0).abs() < 1e-9);
}
+/// A growing non-interactive field is measured in the face its text is drawn in: condensed,
+/// its text takes fewer lines and the field grows only to hold them.
#[test]
fn a_non_interactive_field_is_measured_at_the_scale_its_text_is_drawn() {
// A growing, non-interactive text field (drawn as text by the layout, not as a widget),
@@ -111,6 +116,8 @@ fn a_non_interactive_field_is_measured_at_the_scale_its_text_is_drawn() {
assert!((narrow_gap - full_gap).abs() < 0.01, "the field shrinks with its text: gap {narrow_gap} vs {full_gap}");
}
+/// Condensed text is written with `Tz` inside a `q`/`Q` pair of its own, and full-width text
+/// sets no scale, so none carries over from the text before it.
#[test]
fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() {
let (xdp, _) = scaled_text_template();
diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs
index f880a945cf..05071a5418 100644
--- a/crates/xfa/src/text.rs
+++ b/crates/xfa/src/text.rs
@@ -81,6 +81,8 @@ pub struct Face {
}
impl Face {
+ /// The face a resolved font is drawn in: the standard-14 family its typeface maps to,
+ /// with its size and horizontal scale clamped to a drawable range.
pub fn of(font: &Font) -> Face {
Face {
family: Family::of(&font.typeface),
@@ -93,6 +95,8 @@ impl Face {
}
}
+ /// This face with what a rich-text run sets in place of its own: weight, posture,
+ /// underline, size and horizontal scale. The family and color stay.
pub fn with_run(&self, run: &Run) -> Face {
Face {
family: self.family,
@@ -111,6 +115,8 @@ impl Face {
Face { h_scale: 1.0, ..self }
}
+ /// The resource name of this face's standard font, as content streams and `/DA` strings
+ /// refer to it.
pub fn resource_name(&self) -> &'static str {
self.family.resource_name(self.bold, self.italic)
}
From 24b55275a5b98d423e9bd848a532a596e57ef57b Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:43:12 -0400
Subject: [PATCH 32/45] M12: doc comments for the functions this change touches
---
crates/xfa/src/layout.rs | 9 +++++++++
crates/xfa/src/tests.rs | 16 ++++++++++++----
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index d1943d68fb..91e79de140 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -904,6 +904,9 @@ impl Layouter<'_> {
size
}
+ /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it).
+ /// Without a width, a container, a text draw or a button is as wide as its content (at most
+ /// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content.
fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
let c = node.common();
// The node's own SOM: its repeating children count their instances under it, and a
@@ -1055,6 +1058,9 @@ impl Layouter<'_> {
Some((placement, reserve))
}
+ /// The height of field `f`'s content within `inner_w` (inside its margins): its widget (a check
+ /// box, a button's caption, or one line of text or three when multi-line, more for a text field
+ /// that grows) plus a caption above or below it, or the taller of the two for one beside it.
fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 {
let font = ctx.font.apply(&f.common.font);
let lines = if f.multiline { 3.0 } else { 1.0 };
@@ -1486,6 +1492,9 @@ impl Layouter<'_> {
if *n == 1 { base } else { format!("{base}_{n}") }
}
+ /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its
+ /// widget, or its value as plain text when it is non-interactive, a barcode, or a text field
+ /// taller than its content area. `radio` is set for a group's radio button; `sib` is its SOM index.
fn draw_field(
&mut self,
f: &Field,
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 70ec183c3a..70ffba3387 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -1191,10 +1191,6 @@ fn repeating_subforms_inside_positioned_content_get_their_instances() {
assert!(((after.rect.y - rows[2].rect.y) - step).abs() < 0.01, "after: {:?}, last row: {:?}", after.rect, rows[2].rect);
}
-/// A page area without an `` repeats for as long as the body needs pages. Before, it was
-/// treated like a subform (at most once), so once the first body page was out the layout fell
-/// back to the last page area, here one limited to a single use, and drew its content over every
-/// body page (and listed a copy of its fields per page).
/// A positioned subform holding `body`, laid out; its page's items.
fn positioned(body: &str) -> Vec- {
let xdp = format!(
@@ -1208,6 +1204,8 @@ fn positioned(body: &str) -> Vec
- {
layout_xml(&xdp).unwrap().pages.into_iter().next().unwrap().items
}
+/// A text draw without a width is as wide as its text, so right-aligned text starts at its x; a
+/// minimum width wider than the text still wins, and text longer than the room left still wraps.
#[test]
fn a_label_without_a_width_is_as_wide_as_its_text() {
const TEXT: &str = "PERSONNEL ONLY: from date (YYYYMMDD):";
@@ -1234,6 +1232,8 @@ fn a_label_without_a_width_is_as_wide_as_its_text() {
assert!(lines.len() > 1, "wraps: {} line(s)", lines.len());
}
+/// A button without a width is as wide as its caption, not the subform it sits in, unless its
+/// minimum width is wider.
#[test]
fn a_button_without_a_width_is_as_wide_as_its_caption() {
// No width, a minimum narrower than its caption: as wide as the caption, not the subform.
@@ -1252,6 +1252,8 @@ fn a_button_without_a_width_is_as_wide_as_its_caption() {
assert!((button.rect.w - 200.0).abs() < 0.05, "{}", button.rect.w);
}
+/// A button without a height is as tall as one line of its caption in the caption's own font,
+/// with none of a text field's padding, and never shorter than its minimum height.
#[test]
fn a_button_without_a_height_is_as_tall_as_its_caption() {
// A 10pt field font, an 8pt caption: the button is as tall as its caption's line (no
@@ -1266,6 +1268,8 @@ fn a_button_without_a_height_is_as_tall_as_its_caption() {
assert!((button("10.5pt") - 10.5).abs() < 0.05, "{}", button("10.5pt"));
}
+/// A button's widget is its whole field, at the field's position and size: the field's margins
+/// pad the caption, not the box.
#[test]
fn a_buttons_box_is_the_whole_field_whatever_its_margins() {
// The button's box is its field's border: its margins pad the caption, not the box.
@@ -1277,6 +1281,10 @@ fn a_buttons_box_is_the_whole_field_whatever_its_margins() {
assert!((button.x - (36.0 + 72.0)).abs() < 0.05, "{button:?}");
}
+/// A page area without an `
` repeats for as long as the body needs pages. Before, it was
+/// treated like a subform (at most once), so once the first body page was out the layout fell
+/// back to the last page area, here one limited to a single use, and drew its content over every
+/// body page (and listed a copy of its fields per page).
#[test]
fn page_area_without_occur_serves_every_body_page() {
let section = |n: u32| {
From 5cd7e816b942cb75082b0d47ddc7c8a61059dbf2 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:43:14 -0400
Subject: [PATCH 33/45] M12: doc comments for the functions this change touches
---
crates/xfa/src/fixtures.rs | 8 ++++----
crates/xfa/src/script.rs | 9 +++++++++
crates/xfa/src/tests.rs | 3 +++
3 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index a46bee5a28..b075cc811c 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -114,10 +114,6 @@ fn assemble(objs: Vec>) -> Vec {
out
}
-/// A scripted form (JavaScript events): `qty` (initialised to 2, validated ≤ 100) × `price`
-/// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform;
-/// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons;
-/// `grand`, the calculated sum of the amounts.
/// A hidden subform `details` a script may show, holding a named field and three unnamed
/// objects: a caption, an old caption the form keeps hidden, and a hidden subform. Unnamed
/// objects have no SOM name of their own, so showing `details` must leave the hidden ones hidden.
@@ -142,6 +138,10 @@ pub fn unnamed_hidden_template() -> String {
.to_string()
}
+/// A scripted form (JavaScript events): `qty` (initialised to 2, validated ≤ 100) × `price`
+/// (5) = `total` (calculated); a check box `more` that shows the hidden `details` subform;
+/// a table whose `row` (item, amount) repeats, with `addRow`, `removeRow` and `hello` buttons;
+/// `grand`, the calculated sum of the amounts.
pub fn scripted_template() -> String {
r##"
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index df7ba2fdfb..1d0dc8a867 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -237,6 +237,9 @@ impl Walker<'_> {
self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string())
}
+ /// The live node of field `f`, child `index` of `parent_som`, with its scripts recorded. In an
+ /// exclusion group (`radio` is the group's selected value) its value is its on value when picked,
+ /// else empty; elsewhere it is its data value (as saved) or its template default.
fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode {
let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index);
let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
@@ -276,6 +279,9 @@ impl Walker<'_> {
}
}
+ /// The live nodes of `nodes`, the children of `parent_som`, with their scripts recorded: one per
+ /// instance of a repeating subform (what the data holds, else its initial count). Nothing past
+ /// [`MAX_DEPTH`] is built, and building stops once the object budget is spent.
fn children(&mut self, nodes: &[Node], parent_som: &str, depth: usize) -> Vec {
let mut out = Vec::new();
if depth > MAX_DEPTH {
@@ -445,6 +451,9 @@ pub fn has_scripts(tpl: &Template) -> bool {
/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
/// instance of a repeating subform hide them all).
pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
+ /// Set the presence `ov` gives each of `nodes` (children of `parent_som`) and their descendants,
+ /// down to [`MAX_DEPTH`], and the access it gives a field outside an exclusion group. An unnamed
+ /// draw, area or subform shares its parent's SOM path, so it takes no override of its own.
fn walk(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
if depth > MAX_DEPTH {
return;
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index d1503527cc..ab49e26bf3 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -716,6 +716,9 @@ fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() {
assert!(overrides(&doc).is_empty());
}
+/// Showing a hidden subform through an override leaves its unnamed hidden draw and subform hidden,
+/// in the layout and in the live form scripts see: they share the subform's SOM path, so its
+/// override is not theirs.
#[test]
fn showing_a_subform_leaves_its_unnamed_hidden_objects_hidden() {
let (tpl, _) = parse(&unnamed_hidden_template()).unwrap();
From a3b942021f8d3e28fb6b3c73ab2a920b74c5da80 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:46:45 -0400
Subject: [PATCH 34/45] M12: doc comments, and words keeps its own
---
crates/xfa/src/tests.rs | 3 +++
crates/xfa/src/text.rs | 2 +-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 9784986feb..1d027162b2 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -2085,6 +2085,8 @@ fn draw_spans(xdp: &str) -> Vec {
layout_xml(xdp).unwrap().pages[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.clone()) } else { None }).collect()
}
+/// A label that fits its box but is written with trailing spaces stays on one line and measures
+/// as tall as without them; text wider than the box still wraps.
#[test]
fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() {
// The box is just wide enough for "a. DATE"; the label is written "a. DATE " (as forms
@@ -2107,6 +2109,7 @@ fn a_space_at_the_end_of_a_line_does_not_count_when_fitting_it() {
assert_eq!(baselines.len(), 2, "{spans:?}");
}
+/// Centered and right-aligned text is placed by its drawn width, whatever spaces follow it.
#[test]
fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() {
let w = 200.0;
diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs
index cbfd9fbc52..76035a2c4d 100644
--- a/crates/xfa/src/text.rs
+++ b/crates/xfa/src/text.rs
@@ -144,7 +144,6 @@ struct Word {
width: f64,
}
-/// Break the runs of a paragraph into words (spaces stay attached to the word before them).
/// The width of `w` without the spaces it ends with: what it takes at the end of a line, where
/// a space is never drawn.
fn ink_width(w: &Word) -> f64 {
@@ -152,6 +151,7 @@ fn ink_width(w: &Word) -> f64 {
if ink.len() == w.text.len() { w.width } else { w.face.width(ink) }
}
+/// Break the runs of a paragraph into words (spaces stay attached to the word before them).
fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str) -> String) -> Vec {
let mut out = Vec::new();
for run in runs {
From ec14687c6d622e05696eb65931a2b8e6afdfbd11 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:20:55 -0400
Subject: [PATCH 35/45] M12: a draw without text or a width keeps the width it
has room for
---
crates/xfa/src/layout.rs | 7 +++++--
crates/xfa/src/tests.rs | 10 ++++++++++
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 91e79de140..a5e515594e 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1015,14 +1015,17 @@ impl Layouter<'_> {
}
/// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying
- /// it out at that width keeps it on its lines; `None` for what isn't text.
+ /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws
+ /// nothing.
fn natural_width(&self, v: &Value, font: &Font, para: &Para) -> Option {
let rich = match v {
Value::Text(t) => text::plain(t),
Value::Rich(r) => r.clone(),
_ => return None,
};
- Some(text::natural_width(&rich, para, &Face::of(font), &|id| self.embed(id)) + 0.01)
+ // No text (a bordered spacer or rule): nothing to size to, so it keeps what it has room for.
+ let w = text::natural_width(&rich, para, &Face::of(font), &|id| self.embed(id));
+ (w > 0.0).then_some(w + 0.01)
}
fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 {
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 70ffba3387..48068e7bd6 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -1232,6 +1232,16 @@ fn a_label_without_a_width_is_as_wide_as_its_text() {
assert!(lines.len() > 1, "wraps: {} line(s)", lines.len());
}
+/// A draw without a width and without text (a bordered spacer or rule) keeps the width it has
+/// room for: with no text to size it to, it doesn't shrink to its margins.
+#[test]
+fn an_empty_draw_without_a_width_keeps_the_width_it_has_room_for() {
+ let items = positioned(r#" "#);
+ let widest =
+ items.iter().filter_map(|i| if let Item::Line { from, to, .. } = i { Some((to.0 - from.0).abs()) } else { None }).fold(0.0_f64, f64::max);
+ assert!((widest - 540.0).abs() < 0.5, "the border spans the 7.5in box: {widest}");
+}
+
/// A button without a width is as wide as its caption, not the subform it sits in, unless its
/// minimum width is wider.
#[test]
From ee91673162889d9c6e4226587717e438d5f54983 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:21:31 -0400
Subject: [PATCH 36/45] M12: doc comments for the functions around this change
---
crates/xfa/src/layout.rs | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index a5e515594e..7ddb00b4f6 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -873,6 +873,8 @@ impl Layouter<'_> {
}
}
+ /// The width and height `node` takes in `avail_w`: its own size, else what its content needs
+ /// (containers are measured once per width and depth, and measuring stops at its caps).
fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) {
let c = node.common();
let spent = self.measures.get().saturating_add(1);
@@ -1028,6 +1030,8 @@ impl Layouter<'_> {
(w > 0.0).then_some(w + 0.01)
}
+ /// How tall value `v` is laid out `w` wide: text wrapped in the context's font and paragraph,
+ /// an image at its aspect ratio, and nothing for lines, rectangles and empty values.
fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 {
match v {
Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)),
@@ -1037,6 +1041,8 @@ impl Layouter<'_> {
}
}
+ /// Where field `f`'s caption goes and how much room it takes from the field's box, or `None`
+ /// when it has no caption that takes room (a button's is drawn inside its box).
fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> {
let cap = f.caption.as_ref()?;
if !cap.presence.occupies() || f.ui == Ui::Button {
@@ -1488,6 +1494,8 @@ impl Layouter<'_> {
self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() })
}
+ /// A field name made from `base` that no earlier field of the form has: the first keeps it,
+ /// later ones get `_2`, `_3`, ….
fn unique_name(&mut self, base: &str) -> String {
let base = clean_name(base);
let n = self.names.entry(base.clone()).or_insert(0);
From 15eef03efc6b327b792d2c03dda69d0e40236630 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:23:32 -0400
Subject: [PATCH 37/45] M12: doc comments for the functions around this change
---
crates/xfa/src/layout.rs | 15 +++++++++++++++
crates/xfa/src/parse.rs | 3 +++
crates/xfa/src/pdf.rs | 3 +++
crates/xfa/src/tests.rs | 7 +++++++
4 files changed, 28 insertions(+)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index feb8a6fde3..5571bf9934 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -1004,6 +1004,9 @@ impl Layouter<'_> {
}
}
+ /// The height a value takes at width `w` in the context's font and paragraph: its text as
+ /// measured, a picture scaled to `w` keeping its shape, nothing for a line, a rectangle or
+ /// no value.
fn value_height(&self, v: &Value, w: f64, ctx: &Ctx) -> f64 {
match v {
Value::Text(t) => text::measure_height(&text::plain(t), w, &ctx.para, &Face::of(&ctx.font), &|id| self.embed(id)),
@@ -1013,6 +1016,10 @@ impl Layouter<'_> {
}
}
+ /// Where a field's caption goes and the room it takes there: its reserve, else the height
+ /// of its text above or below, or beside it its text's width plus 2pt, at most 60% of
+ /// `inner_w`. An inline caption goes on the left. `None` for a button, a caption that takes
+ /// no space, or one with neither text nor a reserve.
fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> {
let cap = f.caption.as_ref()?;
if !cap.presence.occupies() || f.ui == Ui::Button {
@@ -1037,6 +1044,9 @@ impl Layouter<'_> {
Some((placement, reserve))
}
+ /// The height of a field at `inner_w`: its widget (a check box's size, or its lines of text,
+ /// grown to hold a long value, plus padding and the widget margin), plus a caption above or
+ /// below it. A caption beside it makes it at least as tall as the caption's text.
fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 {
let font = ctx.font.apply(&f.common.font);
let lines = if f.multiline { 3.0 } else { 1.0 };
@@ -1089,6 +1099,8 @@ impl Layouter<'_> {
text::measure_height(&text::plain(value.trim_end()), w, &ctx.para, &Self::field_text_face(f, &font), &|_| String::new())
}
+ /// The height of an exclusion group's fields at `inner_w`, by the group's layout: stacked
+ /// top to bottom, at their own positions, or packed in rows.
fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 {
let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
match g.layout {
@@ -1457,6 +1469,9 @@ impl Layouter<'_> {
self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() })
}
+ /// The widget name for a field or group named `base`, cleaned (dots become underscores,
+ /// control characters go): the first use is the name itself, later ones `name_2`, `name_3`
+ /// and so on.
fn unique_name(&mut self, base: &str) -> String {
let base = clean_name(base);
let n = self.names.entry(base.clone()).or_insert(0);
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index 3ebdc92c73..e11ce3060e 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -40,6 +40,8 @@ pub fn measure(s: &str) -> Option {
Some((v * k).clamp(-100_000.0, 100_000.0))
}
+/// The attribute `name` of `n` read as a measurement in points (see [`measure`]); `None`
+/// when it is missing or unreadable.
fn attr_measure(n: XmlNode, name: &str) -> Option {
n.attribute(name).and_then(measure)
}
@@ -51,6 +53,7 @@ fn scale(v: &str) -> Option {
(v.is_finite() && v > 0.0).then(|| (v / 100.0).clamp(0.01, 10.0))
}
+/// The first child element of `n` named `name` (by local name, in any namespace).
fn child<'a, 'd>(n: XmlNode<'a, 'd>, name: &str) -> Option> {
n.children().find(|c| c.is_element() && c.tag_name().name() == name)
}
diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs
index 00d473f0da..bbde05ab0d 100644
--- a/crates/xfa/src/pdf.rs
+++ b/crates/xfa/src/pdf.rs
@@ -509,6 +509,9 @@ impl Emitter<'_> {
Some(r)
}
+ /// Write a new image XObject for a picture, tagged with its `hash`: a JPEG as it is, a PNG
+ /// or GIF decoded to samples with a soft mask for any transparency. `None`, with a warning,
+ /// when the picture can't be read or is another kind.
fn new_image_xobject(&mut self, data: &[u8], content_type: &str, hash: &[u8; 32]) -> Option {
let what = if content_type.is_empty() { "picture" } else { content_type };
let mut d = Dict::new();
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 3d01245abd..0645633f11 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -13,14 +13,18 @@ use crate::layout::WidgetKind;
use crate::model::*;
use crate::script::{NodeKind, Overrides, apply_overrides, fields_by_som, form_tree, overrides, rerender, set_overrides};
+/// The widgets laid out on `page`, in order.
fn widgets(page: &Page) -> Vec<&Widget> {
page.items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w.as_ref()) } else { None }).collect()
}
+/// The text of each text span laid out on `page`, in order.
fn texts(page: &Page) -> Vec {
page.items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.clone()) } else { None }).collect()
}
+/// Measurements in points, inches, millimeters and centimeters convert to points, a bare
+/// number is points, and an `em` length, a non-number or a NaN is `None`.
#[test]
fn measurements_convert_to_points() {
assert_eq!(measure("72pt"), Some(72.0));
@@ -137,6 +141,9 @@ fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() {
assert!(ops.lines().filter(|l| l.contains(" Tj ") && !l.contains(" Tz ")).all(|l| l.starts_with("BT ")), "{ops}");
}
+/// The sample template parses without warnings into what the layout uses: its page areas,
+/// page-number draws, rich-text title, field font, caption and widget border, check box,
+/// date field, radio group, table columns and page break all come through.
#[test]
fn the_template_parses_with_fonts_captions_and_rich_text() {
let (tpl, warnings) = parse(&template(2)).unwrap();
From f9daeeb1fa613748da34fa959536be0071e451b5 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:25:19 -0400
Subject: [PATCH 38/45] M12: doc comments for the functions around this change
---
crates/xfa/src/fixtures.rs | 2 ++
crates/xfa/src/script.rs | 4 ++++
crates/xfa/src/tests.rs | 18 ++++++++++++++++++
3 files changed, 24 insertions(+)
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index b075cc811c..fb957b3cf6 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -96,6 +96,8 @@ pub fn static_shell(data: &str) -> Vec {
assemble(objs)
}
+/// A PDF file holding `objs` as objects 1, 2, … in order, with a cross-reference table and a
+/// trailer whose root is object 1.
fn assemble(objs: Vec>) -> Vec {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 1d0dc8a867..f552de778d 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -186,6 +186,9 @@ impl Walker<'_> {
true
}
+ /// Record the scripts of the object at `som` as events: each non-empty event script, then its
+ /// calculate and validate scripts (the validate one with `message`). Once [`MAX_EVENTS`] events
+ /// are recorded, nothing more is and the form is marked truncated.
fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) {
if self.events.len() >= MAX_EVENTS {
self.truncated = true;
@@ -233,6 +236,7 @@ impl Walker<'_> {
name.and_then(|_| self.ov.presence.get(som)).cloned().unwrap_or_else(|| presence_name(p).to_string())
}
+ /// The access of the object at `som`: a script's, or else the template's `a`.
fn access(&self, som: &str, a: Access) -> String {
self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string())
}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index ab49e26bf3..5a7a685a71 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -539,6 +539,9 @@ fn xfa_bytes(doc: &Document) -> Vec {
s.decoded_within(1 << 20).unwrap()
}
+/// Writing field values into the datasets keeps every data node no field binds to as written,
+/// changes the bound values in place and adds a missing one beside them. Writing the same values
+/// again changes nothing, and a field bound to structured content is reported, not flattened.
#[test]
fn writing_values_keeps_the_data_no_field_binds_to() {
// Regression: the data element was rebuilt from the fields alone, dropping everything else.
@@ -579,6 +582,9 @@ fn writing_values_keeps_the_data_no_field_binds_to() {
assert!(String::from_utf8(xfa_bytes(&doc)).unwrap().contains("rich
"));
}
+/// A UTF-16 packet in either byte order, with a byte order mark, has its values read exactly and
+/// is written back as UTF-16 with its mark. A packet in neither UTF-8 nor UTF-16 is left alone,
+/// and that is reported.
#[test]
fn utf16_packets_are_read_and_written_back_as_utf16() {
let mut doc = Document::open(Arc::new(static_shell("Zoë 日本 ünbound "))).unwrap();
@@ -608,6 +614,9 @@ fn utf16_packets_are_read_and_written_back_as_utf16() {
assert_eq!(xfa_bytes(&doc), latin1);
}
+/// Hundreds of fields with absurd SOM indices finish within seconds, both writing the datasets and
+/// building fresh data, create at most the node budget and report what they could not write. A
+/// moderate index still works, creating the empty instances before it.
#[test]
fn huge_som_indices_are_capped_not_built() {
// Regression: each index created its missing siblings one count at a time (quadratic), with
@@ -647,6 +656,9 @@ fn huge_som_indices_are_capped_not_built() {
assert!(d.get(&som_to_path("form1[0].row[2]")).is_some(), "the instances in between exist, empty");
}
+/// The live form tree carries each object's SOM path, value, presence and access, one instance of
+/// a repeating row without data, and the template's event, calculate and validate scripts (FormCalc
+/// when no content type is given). Data adds rows and values, and an override changes presence.
#[test]
fn the_form_tree_carries_values_presence_instances_and_the_events() {
let xml = scripted_template();
@@ -681,6 +693,9 @@ fn the_form_tree_carries_values_presence_instances_and_the_events() {
assert_eq!(page.children.iter().find(|c| c.name == "details").unwrap().kind, Some(NodeKind::Subform));
}
+/// Presence and access overrides are kept on the AcroForm and applied to the template: `note` has
+/// no widget while `details` is hidden and gets one once an override shows it and the form is laid
+/// out again. Saving and reopening keeps the overrides and one set of fields; storing none removes the key.
#[test]
fn overrides_live_in_the_acroform_and_hide_objects_in_the_layout() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
@@ -739,6 +754,9 @@ fn showing_a_subform_leaves_its_unnamed_hidden_objects_hidden() {
assert_eq!(details.children.iter().find(|c| c.kind == Some(NodeKind::Subform)).map(|c| c.presence.as_str()), Some("hidden"));
}
+/// Adding and removing data instances edits the datasets packet, creating it when missing: asking
+/// for no more rows than exist, or removing one that is not there, changes nothing, and removing a
+/// row moves the later ones up. The layout follows the data, and an absurd count is capped, not built.
#[test]
fn data_instances_are_added_and_removed_in_the_packet() {
let mut doc = Document::open(Arc::new(shell(&scripted_template()))).unwrap();
From 572e01a71d15b79b99fa6651cd54462e65f1a936 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:39:32 -0400
Subject: [PATCH 39/45] M12: the wrapping test checks the text stays within its
room
---
crates/xfa/src/tests.rs | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 48068e7bd6..b9c71229e4 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -1230,6 +1230,12 @@ fn a_label_without_a_width_is_as_wide_as_its_text() {
let lines: std::collections::BTreeSet =
items.iter().filter_map(|i| if let Item::Text(s) = i { Some((s.baseline * 100.0).round() as i64) } else { None }).collect();
assert!(lines.len() > 1, "wraps: {} line(s)", lines.len());
+ // And within the room it has: no line's drawn text runs past the subform's right edge.
+ let right = items
+ .iter()
+ .filter_map(|i| if let Item::Text(s) = i { Some(s.x + s.face.width(s.text.trim_end())) } else { None })
+ .fold(f64::NEG_INFINITY, f64::max);
+ assert!(right <= 36.0 + 7.5 * 72.0 + 0.05, "the text stays within the available width: {right}");
}
/// A draw without a width and without text (a bordered spacer or rule) keeps the width it has
From 7176ebde9422c413843aa5209bec8d2f0b62a79e Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:29:08 -0400
Subject: [PATCH 40/45] M6: XFA formattedValue shows a date in the field's
display picture
Fields keep their . A date display picture turns the
ISO value into its pattern for scripts' formattedValue (JavaScript and
FormCalc), so date arithmetic on formattedValue works as forms expect.
Once a script sets the value, formattedValue gives that value. Number
and text display pictures still give the raw value.
---
crates/engine/src/tests.rs | 14 +++++++++++
crates/engine/src/xfa.rs | 1 +
crates/js/src/formcalc.rs | 6 ++++-
crates/js/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++++
crates/js/src/xfa.rs | 11 ++++++++-
crates/xfa/src/fixtures.rs | 41 +++++++++++++++++++++++++++++++
crates/xfa/src/layout.rs | 14 +++++++++++
crates/xfa/src/lib.rs | 2 +-
crates/xfa/src/model.rs | 3 +++
crates/xfa/src/parse.rs | 1 +
crates/xfa/src/script.rs | 4 +++
crates/xfa/src/tests.rs | 26 ++++++++++++++++++++
parity/acrobat-features.toml | 4 +--
13 files changed, 169 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index f125c41dac..41d1c8dcf7 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4469,6 +4469,20 @@ fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]);
}
+/// A row of dates worked out the way leave forms do it: the calculation checks that the raw value
+/// is a whole ISO date, then adds days to the date as `formattedValue` shows it (`YYYYMMDD`).
+#[test]
+fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() {
+ let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_row_template()));
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("row.pdf", None, bytes, None).expect("opens");
+ let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
+ s.apply(id, Edit::SetFieldValue { name: "from".into(), value: FieldValue::Text("20261020".into()) }).unwrap();
+ s.apply(id, Edit::SetFieldValue { name: "days".into(), value: FieldValue::Text("3".into()) }).unwrap();
+ let out = s.take_js_output(id);
+ assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors);
+}
+
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 0f37933afd..43e00a5780 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -149,6 +149,7 @@ fn to_js(n: &FormNode) -> XfaNode {
occur_max: n.occur_max,
index: n.index,
children: n.children.iter().map(to_js).collect(),
+ formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)),
}
}
diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs
index dc32d457bf..729cb1a889 100644
--- a/crates/js/src/formcalc.rs
+++ b/crates/js/src/formcalc.rs
@@ -1100,7 +1100,11 @@ impl Interp<'_> {
fn node_member(&mut self, i: usize, name: &str) -> R {
let Some(n) = self.node(i) else { return Ok(Val::Null) };
Ok(match name {
- "rawValue" | "value" | "formattedValue" => self.node_value(i),
+ "formattedValue" => match &n.formatted {
+ Some(f) => Val::Str(f.clone()),
+ None => self.node_value(i),
+ },
+ "rawValue" | "value" => self.node_value(i),
"name" => Val::Str(n.name.clone()),
"somExpression" => Val::Str(n.som.clone()),
"className" => Val::Str(n.kind.class_name().into()),
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index 61d3a71e51..b04f3c06c6 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -395,6 +395,38 @@ mod xfa_model {
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]);
}
+ /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020.
+ pub(super) fn dated_form() -> XfaNode {
+ let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) };
+ XfaNode {
+ name: "form1".into(),
+ som: "form1[0]".into(),
+ kind: Some(XfaKind::Subform),
+ children: vec![XfaNode {
+ name: "page1".into(),
+ som: "form1[0].page1[0]".into(),
+ kind: Some(XfaKind::Subform),
+ children: vec![d],
+ ..Default::default()
+ }],
+ ..Default::default()
+ }
+ }
+
+ /// `formattedValue` reads the value as the field's picture shows it, and `rawValue` the
+ /// value itself; once the script sets the value, `formattedValue` gives that value.
+ #[test]
+ fn formatted_value_reads_the_shown_value_until_the_script_sets_one() {
+ let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
+ let doc = XfaDoc::default();
+ let run = |script: &str| run_xfa(script, &ev, &doc, &dated_form(), Limits::default());
+ let o = run("this.formattedValue + ' ' + this.rawValue");
+ assert_eq!(o.error, None, "{o:?}");
+ assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
+ let o = run("this.rawValue = '2026-10-21'; this.formattedValue");
+ assert_eq!(o.result.as_deref(), Some("2026-10-21"));
+ }
+
#[test]
fn long_values_and_results_are_cut_where_they_are_stored() {
// The value kept for later reads is the one the effect saves.
@@ -607,6 +639,21 @@ mod formcalc {
o.result.unwrap_or_default()
}
+ /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script
+ /// sets the value.
+ #[test]
+ fn formcalc_formatted_value_reads_the_shown_value_until_the_script_sets_one() {
+ let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
+ let doc = XfaDoc::default();
+ let run = |script: &str| run_formcalc(script, &ev, &doc, &super::xfa_model::dated_form(), Limits::default());
+ let o = run("Concat($.formattedValue, \" \", $.rawValue)");
+ assert_eq!(o.error, None, "{o:?}");
+ assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
+ let o = run("$.rawValue = \"2026-10-21\"\n$.formattedValue");
+ assert_eq!(o.error, None, "{o:?}");
+ assert_eq!(o.result.as_deref(), Some("2026-10-21"));
+ }
+
#[test]
fn arithmetic_strings_and_comparisons_follow_formcalc_rules() {
assert_eq!(calc("1 + 2 * 3"), "7");
diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs
index a2361da7e4..a1be639cad 100644
--- a/crates/js/src/xfa.rs
+++ b/crates/js/src/xfa.rs
@@ -64,6 +64,9 @@ pub struct XfaNode {
/// This instance's index among its same-named siblings.
pub index: usize,
pub children: Vec,
+ /// Fields: the value shown in the field's display picture, what `formattedValue` reads;
+ /// `None` when the picture doesn't change it (the raw value is read then).
+ pub formatted: Option,
}
/// The event a script runs for.
@@ -205,6 +208,8 @@ pub(crate) struct HNode {
pub(crate) children: Vec,
/// Removed by `removeInstance`: skipped everywhere.
pub(crate) gone: bool,
+ /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows.
+ pub(crate) formatted: Option,
}
/// The form as a script sees and changes it, with the effects it records.
@@ -346,6 +351,7 @@ impl XHost {
// the snapshot stays bounded however often a long value is written.
let (text, cut) = clip_value(text);
n.value = text.clone();
+ n.formatted = None;
let som = n.som.clone();
if cut {
self.clipped_values += 1;
@@ -381,6 +387,7 @@ fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usiz
parent,
children: Vec::new(),
gone: false,
+ formatted: n.formatted.clone(),
});
let mut kids = Vec::new();
for c in &n.children {
@@ -618,6 +625,8 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize)
parent,
children: Vec::new(),
gone: false,
+ // The copy holds no value, so nothing is shown in its picture either.
+ formatted: None,
},
n.children.clone(),
)
@@ -853,7 +862,7 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsValue::from(idx as f64),
"rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js),
- "formattedValue" => s(&value),
+ "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)),
"name" => s(&name),
"somExpression" => s(&som),
"className" => s(kind.class_name()),
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index 54bb9bc216..4c8f29df53 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -352,6 +352,47 @@ pub fn many_calculations_template(sections: usize, rows: usize) -> String {
)
}
+/// Shaped like a leave form's row of dates: `from`, a date field typed and shown as `YYYYMMDD`
+/// (its data keeps ISO dates), `days`, an invisible `minus1` one less than `days`, and `to`,
+/// which a calculation works out as `from` plus `minus1` days. As such forms do, it checks that
+/// `from`'s raw value is a whole ISO date, then does its date arithmetic on `formattedValue`.
+pub fn date_row_template() -> String {
+ r##"
+
+
+
+
+
+ date{YYYYMMDD} date{YYYYMMDD}
+
+
+
+
+
+
+
+"##
+ .to_string()
+}
+
/// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields.
pub fn shell(xdp: &str) -> Vec {
let objs: Vec> = vec![
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 62d7beedf1..75ffae923c 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -405,6 +405,20 @@ fn date_pattern(picture: Option<&str>) -> String {
inner.replace('E', "d").to_ascii_lowercase()
}
+/// What scripts read as a field's `formattedValue` when its display picture is `picture`: the
+/// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into
+/// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a
+/// clause this can't read and for a value that isn't an ISO date: the raw value stands then.
+pub fn formatted_value(picture: &str, raw: &str) -> Option {
+ let first = picture.split('|').next()?.trim();
+ let rest = first.strip_prefix("date")?;
+ if !rest.starts_with(['{', '(']) {
+ return None;
+ }
+ let shown = iso_to_pattern(raw, &date_pattern(Some(first)));
+ (shown != raw).then_some(shown)
+}
+
fn button_action(scripts: &[Script]) -> Option {
let s = scripts.iter().find(|s| s.activity == "click")?;
let t = &s.text;
diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs
index bf4414970e..4b42d39619 100644
--- a/crates/xfa/src/lib.rs
+++ b/crates/xfa/src/lib.rs
@@ -95,7 +95,7 @@ pub use data::{
pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets,
write_datasets_reusing, write_datasets_with,
};
-pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout};
+pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout};
pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets};
pub use parse::{measure, parse};
pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape};
diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs
index 572675f958..327f7fc417 100644
--- a/crates/xfa/src/model.rs
+++ b/crates/xfa/src/model.rs
@@ -503,6 +503,9 @@ pub struct Field {
pub validate_message: Option,
/// Numeric fields (numericEdit, or a decimal/integer/float value) give scripts numbers.
pub numeric: bool,
+ /// The display picture clause (``), e.g. `date{YYYYMMDD}`: how the value
+ /// shows when not being edited, and what scripts read as `formattedValue`.
+ pub display_picture: Option,
}
impl Field {
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index 4582d59b22..c753e5aedb 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -475,6 +475,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field {
validate: validate_el.and_then(|v| child(v, "script")).map(|s| parse_script(s, "validate")),
validate_message,
numeric,
+ display_picture: child(n, "format").and_then(|f| child(f, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
}
}
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 17a0376641..f75235e200 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -44,6 +44,8 @@ pub struct FormNode {
pub occur_max: Option,
pub index: usize,
pub children: Vec,
+ /// Fields: the display picture (see [`Field::display_picture`]), for `formattedValue`.
+ pub display_picture: Option,
}
/// A script the template runs for an event of one node instance.
@@ -261,6 +263,7 @@ impl Walker<'_> {
occur_max: Some(1),
index,
children: Vec::new(),
+ display_picture: f.display_picture.clone(),
}
}
@@ -314,6 +317,7 @@ impl Walker<'_> {
occur_max: sf.occur.max,
index,
children,
+ display_picture: None,
});
}
}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 03eb2c67ad..963056f10b 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -336,6 +336,32 @@ fn dates_convert_between_iso_and_acrobat_patterns() {
assert_eq!(pattern_to_iso("hello", "mm/dd/yyyy"), None);
}
+/// What scripts read as `formattedValue`: a date display picture shows the ISO value in its
+/// pattern (the first of several alternatives); other kinds of picture, and values that aren't
+/// ISO dates, leave the raw value.
+#[test]
+fn formatted_values_follow_date_display_pictures() {
+ assert_eq!(crate::formatted_value("date{YYYYMMDD}", "2026-10-20").as_deref(), Some("20261020"));
+ assert_eq!(crate::formatted_value("date{MM/DD/YYYY}", "2026-10-20").as_deref(), Some("10/20/2026"));
+ assert_eq!(crate::formatted_value("date{YYYYMMDD}|date{DDMMMYY}", "2026-10-20").as_deref(), Some("20261020"));
+ assert_eq!(crate::formatted_value("date{YYYY-MM-DD}", "2026-10-20"), None, "shown as it is");
+ assert_eq!(crate::formatted_value("num{zzz9}", "2026-10-20"), None);
+ assert_eq!(crate::formatted_value("date{YYYYMMDD}", "20261020"), None, "not an ISO date");
+ assert_eq!(crate::formatted_value("date{YYYYMMDD}", ""), None);
+}
+
+/// A field's `` is its display picture, kept apart from its `` edit
+/// picture; a field without one has none.
+#[test]
+fn display_pictures_are_parsed_apart_from_edit_pictures() {
+ let (tpl, _) = parse(&crate::fixtures::date_row_template()).unwrap();
+ let Node::Subform(page) = &tpl.root.children[0] else { panic!() };
+ let Node::Field(from) = &page.children[0] else { panic!() };
+ assert_eq!((from.picture.as_deref(), from.display_picture.as_deref()), (Some("date{YYYYMMDD}"), Some("date{YYYYMMDD}")));
+ let Node::Field(days) = &page.children[1] else { panic!() };
+ assert_eq!(days.display_picture, None);
+}
+
#[test]
fn data_fills_the_fields_and_adds_rows() {
let form = layout_xml(&template_with_data(1, DATA)).unwrap();
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 7aca7d5c8b..0c99de7b67 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5220,8 +5220,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"]
-notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
+evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"]
+notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"
From c0a513eed8a940b7b2cd0e7d734e9b8916f34913 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:32:16 -0400
Subject: [PATCH 41/45] M12: scripts read a date in its data picture as the ISO
date
A date the field's data picture writes as 20261018 reaches scripts as
2026-10-18, the rawValue another viewer gives them, so a script that
checks String(rawValue).length == 10 works (DA Form 31's 10b TO).
Doc comments on the functions this branch touches.
---
crates/engine/src/tests.rs | 5 +++++
crates/xfa/src/data.rs | 4 ++++
crates/xfa/src/layout.rs | 4 ++++
crates/xfa/src/parse.rs | 2 ++
crates/xfa/src/script.rs | 19 +++++++++++++++----
crates/xfa/src/tests.rs | 24 ++++++++++++++++++++++++
parity/acrobat-features.toml | 4 ++--
7 files changed, 56 insertions(+), 6 deletions(-)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 9365e744cb..27a6a3b4f2 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4155,6 +4155,8 @@ fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() {
assert!(!doc.dirty);
}
+/// A static XFA form's fields take their values from the datasets on opening, and edits are
+/// written back there.
#[test]
fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada 1 "));
@@ -4206,6 +4208,8 @@ fn xfa_dates_are_written_and_read_back_in_their_data_picture() {
assert_eq!(value("when"), vec!["10/18/2026".to_string()]);
}
+/// Same-named date fields are each written in their own data picture, and a data picture
+/// without a day keeps the ISO date.
#[test]
fn each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::sibling_date_template("")));
@@ -4231,6 +4235,7 @@ fn each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keep
}
}
+/// Data no field binds to (other namespaces, history) survives an edit and a save.
#[test]
fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
// Regression: editing a field replaced the whole data element with the fields' values.
diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs
index 678b1d9752..79ca6e166e 100644
--- a/crates/xfa/src/data.rs
+++ b/crates/xfa/src/data.rs
@@ -553,6 +553,8 @@ fn path_of(tpl: Option<&Template>, som: &str) -> DataPath {
}
}
+/// The values the datasets should hold for `fields`, by data path: dates in each field's data
+/// picture (or ISO), check and radio states, choice values as saved.
fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec {
let mut out = Vec::new();
for f in fields {
@@ -1636,6 +1638,8 @@ pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldD
with_template(doc, tpl, |t| values_at(doc, t, fields))
}
+/// The values the document's datasets hold for `fields`, read in each field's data picture and
+/// shown in its own pattern; only fields the data mentions.
fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> {
let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() };
let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() };
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index b7c2605a6e..478936188d 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -399,6 +399,8 @@ fn date_pattern(picture: Option<&str>) -> String {
picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into())
}
+/// The PDF action a button's click script becomes: a native one for a single-statement idiom
+/// (reset, print, save, a link), else the script itself.
fn button_action(scripts: &[Script]) -> Option {
let s = scripts.iter().find(|s| s.activity == "click")?;
let t = &s.text;
@@ -1450,6 +1452,8 @@ impl Layouter<'_> {
if *n == 1 { base } else { format!("{base}_{n}") }
}
+ /// Lay out field `f` in `rect`: its caption, its widget of the right kind, and its value
+ /// from the data.
fn draw_field(
&mut self,
f: &Field,
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index ac02e5119a..abe3f1a42c 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -407,6 +407,8 @@ fn parse_items(n: XmlNode, warnings: &mut Vec) -> (Vec, Vec` element as the model's [`Field`]: its widget kind, caption, value, items,
+/// pictures (edit and data), scripts and access.
fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field {
let ui = child(n, "ui");
let ui_el = ui.and_then(|u| u.children().find(|c| c.is_element() && c.tag_name().name() != "extras" && c.tag_name().name() != "picture"));
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 9d158ac6cd..f74293a675 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -235,6 +235,8 @@ impl Walker<'_> {
self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string())
}
+ /// The live node of field `f`, instance `index` under `parent_som`: its value from the data
+ /// (as scripts read it) or its default, a radio button's from its group's choice `radio`.
fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode {
let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index);
let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
@@ -244,7 +246,7 @@ impl Walker<'_> {
let on = f.items.first().cloned().unwrap_or_default();
if selected == on { on } else { String::new() }
}
- (None, Some(v)) => f.saved_value(&v),
+ (None, Some(v)) => f.saved_value(&Walker::as_scripts_read(f, &v)),
(None, None) => f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(),
};
self.push_events(&som, &f.scripts, f.calculate.as_ref(), f.validate.as_ref(), f.validate_message.as_deref());
@@ -264,12 +266,19 @@ impl Walker<'_> {
}
}
- /// What [`Self::field`] gives a field outside an exclusion group: its data value (as saved)
- /// or its template default.
+ /// A value the data holds as scripts read it: a date the field's data picture writes in
+ /// another form (`20261018`) is the ISO date (`2026-10-18`) another viewer gives scripts as
+ /// `rawValue`. Other values, and dates that don't match the picture, are as they are.
+ fn as_scripts_read(f: &Field, v: &str) -> String {
+ crate::data::data_to_iso(v, crate::data::data_date_letters(f).as_deref())
+ }
+
+ /// What [`Self::field`] gives a field outside an exclusion group: its data value (as saved,
+ /// as scripts read it) or its template default.
fn plain_field_value(tpl: &Template, data: Option<&DataNode>, f: &Field, som: &str) -> String {
let data_value = data.and_then(|d| d.value_at(&data_path(tpl, som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
match data_value {
- Some(v) => f.saved_value(&v),
+ Some(v) => f.saved_value(&Walker::as_scripts_read(f, &v)),
None => f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(),
}
}
@@ -602,6 +611,8 @@ pub fn refresh_value(live: &mut FormNode, tpl: &Template, data: Option<&DataNode
if path.is_empty() {
return None;
}
+ /// Down the one branch of `n` that `path` leads to from segment `pos`, giving each field
+ /// bound there its value from `data`; `None` when something there needs a rebuild.
fn walk(
n: &mut FormNode,
tpl: &Template,
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 0a192479a7..b6c861a53e 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -325,6 +325,7 @@ fn datasets_parse_into_a_tree_and_som_paths_find_their_nodes() {
assert!(parse_datasets("").is_none());
}
+/// Dates convert between ISO and date patterns both ways, and what isn't a date is kept.
#[test]
fn dates_convert_between_iso_and_acrobat_patterns() {
assert_eq!(iso_to_pattern("2001-02-03", "yyyy-mm-dd"), "2001-02-03");
@@ -350,6 +351,7 @@ fn a_date_is_read_from_the_data_in_its_data_picture() {
assert_eq!(by("when").value.as_deref(), Some("10/18/2026"), "no data picture: the data holds ISO");
}
+/// A SOM path names the template field its segment numbers count to, repeating rows included.
#[test]
fn som_paths_name_the_template_field_their_numbers_count_to() {
let xml = r#"
@@ -373,6 +375,7 @@ fn som_paths_name_the_template_field_their_numbers_count_to() {
assert_eq!(picture("form[0].u[0]").as_deref(), Some("inner"));
}
+/// Refreshing a value in place uses the field's own template field, not a same-named one's.
#[test]
fn a_refreshed_field_takes_its_value_from_its_own_template_field() {
// Two text fields named `x` with different defaults; the data holds only the first.
@@ -391,6 +394,24 @@ fn a_refreshed_field_takes_its_value_from_its_own_template_field() {
assert_eq!(value(&live, "form[0].x[1]").as_deref(), Some("second"), "not the first x's default");
}
+/// Scripts read a date as the ISO date another viewer gives them as `rawValue`
+/// (`2026-10-18`), whatever the field's data picture writes in the data (`20261018`), so a
+/// script's `String(rawValue).length == 10` holds. A field without a data picture keeps the
+/// data's ISO date, and refreshing a value in place reads it the same way.
+#[test]
+fn scripts_read_a_date_as_iso_whatever_its_data_picture_writes() {
+ let xml = bound_date_template("");
+ let (tpl, _) = parse(&xml).unwrap();
+ let data = parse_datasets(&xml).unwrap();
+ let mut live = form_tree(&tpl, Some(&data), &Overrides::default()).root;
+ let value = |live: &crate::script::FormNode, som: &str| node_at(live, som).map(|n| n.value.clone());
+ assert_eq!(value(&live, "form[0].page[0].due[0]").as_deref(), Some("2026-10-18"));
+ assert_eq!(value(&live, "form[0].page[0].when[0]").as_deref(), Some("2026-10-18"));
+ let later = parse_datasets(&bound_date_template("")).unwrap();
+ assert_eq!(crate::script::refresh_value(&mut live, &tpl, Some(&later), "form[0].page[0].due[0]"), Some(true));
+ assert_eq!(value(&live, "form[0].page[0].due[0]").as_deref(), Some("2026-10-19"));
+}
+
/// The node of `live` at `som`.
fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate::script::FormNode> {
if live.som == som {
@@ -399,6 +420,8 @@ fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate
live.children.iter().find_map(|c| node_at(c, som))
}
+/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads
+/// back as written.
#[test]
fn a_date_its_data_picture_cant_hold_is_written_as_iso() {
use crate::data::{data_to_iso, iso_to_data};
@@ -410,6 +433,7 @@ fn a_date_its_data_picture_cant_hold_is_written_as_iso() {
}
}
+/// The datasets fill the fields and add the rows of repeating subforms.
#[test]
fn data_fills_the_fields_and_adds_rows() {
let form = layout_xml(&template_with_data(1, DATA)).unwrap();
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index e906628705..bd5f0c5a9c 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5244,8 +5244,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open", "file.save", "form.export_data", "form.import_data"]
tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"]
-evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"]
-notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
+evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::a_date_its_data_picture_cant_hold_is_written_as_iso", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"]
+notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)"
[[feature]]
id = "form.xfa-flatten"
From 6a8ea287aa1a0e8ca4509c9e6dc7e27f5a4d3deb Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:36:29 -0400
Subject: [PATCH 42/45] M6: doc comments on the functions the formattedValue
change touches
---
crates/engine/src/tests.rs | 14 ++++++++++++++
crates/engine/src/xfa.rs | 1 +
crates/js/src/formcalc.rs | 2 ++
crates/js/src/tests.rs | 5 +++++
crates/js/src/xfa.rs | 7 +++++++
crates/xfa/src/layout.rs | 2 ++
crates/xfa/src/parse.rs | 2 ++
crates/xfa/src/script.rs | 8 ++++++++
crates/xfa/src/tests.rs | 4 ++++
9 files changed, 45 insertions(+)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 41d1c8dcf7..f1260084fe 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4279,6 +4279,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]);
}
+/// A form's data exports, imports and takes pictures through the session, and what it holds
+/// comes back the same.
#[test]
fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() {
use pdfcraft_xfa::som_to_path;
@@ -4360,6 +4362,8 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() {
assert_eq!(d2.page_images(0).len(), 2);
}
+/// Importing data into a static form replaces every value, emptying the fields the file
+/// doesn't name, and pictures chosen for it reach its data.
#[test]
fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_data() {
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada 1 "));
@@ -4380,6 +4384,8 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_
assert!(xml.contains("Bob ") && !xml.contains("1 "), "{xml}");
}
+/// Importing data into a dynamic form adds and removes the rows of its repeating subform to
+/// match, and the calculations over them run again.
#[test]
fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
@@ -4412,6 +4418,8 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
assert!(String::from_utf8_lossy(&out).contains("- only
"));
}
+/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine within
+/// the opening's budget, and a change recalculates them within the event's.
#[test]
fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
let (sections, rows) = (10, 20);
@@ -4442,6 +4450,9 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
eprintln!("recalculated after a change in {:?}", started.elapsed());
}
+/// The scripts of one event share the form as the engine holds it: a failed script's
+/// half-done writes reach nobody, and a script reads the saved value a choice list was given
+/// by its shown text.
#[test]
fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() {
// In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate
@@ -4483,6 +4494,9 @@ fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() {
assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors);
}
+/// A scripted form end to end: initialize and calculate on open, recalculation after a change,
+/// a failing validation's message, buttons that add and remove rows and show messages, a check
+/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all.
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 43e00a5780..34c2de15e5 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -129,6 +129,7 @@ pub(crate) struct XfaRun<'a> {
pub ran_away: bool,
}
+/// The live form node `n`, with its descendants, as the script engine's snapshot of it.
fn to_js(n: &FormNode) -> XfaNode {
XfaNode {
name: n.name.clone(),
diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs
index 729cb1a889..74f8fef467 100644
--- a/crates/js/src/formcalc.rs
+++ b/crates/js/src/formcalc.rs
@@ -1097,6 +1097,8 @@ impl Interp<'_> {
self.charge(1)
}
+ /// Property `name` of form object `i` as FormCalc reads it: its value, attributes,
+ /// relatives and children by name.
fn node_member(&mut self, i: usize, name: &str) -> R {
let Some(n) = self.node(i) else { return Ok(Val::Null) };
Ok(match name {
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index b04f3c06c6..e2c51c4379 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -380,12 +380,15 @@ mod xfa_model {
}
}
+ /// Run the JavaScript `script` for `activity` on the test form, from the node at `target`.
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 };
run_xfa(script, &ev, &doc, &form(), Limits::default())
}
+ /// A calculate script finds its siblings by name and yields its last expression, and a
+ /// value it sets comes back as an effect.
#[test]
fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() {
let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate");
@@ -427,6 +430,7 @@ mod xfa_model {
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
}
+ /// Long values a script sets, and long results, are cut where they are stored, with a note.
#[test]
fn long_values_and_results_are_cut_where_they_are_stored() {
// The value kept for later reads is the one the effect saves.
@@ -654,6 +658,7 @@ mod formcalc {
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
}
+ /// FormCalc arithmetic, string conversion and comparisons follow FormCalc's own rules.
#[test]
fn arithmetic_strings_and_comparisons_follow_formcalc_rules() {
assert_eq!(calc("1 + 2 * 3"), "7");
diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs
index a1be639cad..5a693db9f0 100644
--- a/crates/js/src/xfa.rs
+++ b/crates/js/src/xfa.rs
@@ -363,10 +363,13 @@ impl XHost {
type Shared = RefCell;
+/// The form host of the script running in `ctx`.
fn host(ctx: &Context) -> JsResult<&Shared> {
ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up"))
}
+/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`]
+/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits.
fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option {
if depth > MAX_DEPTH || out.len() >= MAX_NODES {
return None;
@@ -826,6 +829,8 @@ pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec {
cur
}
+/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document
+/// order, to [`MAX_DEPTH`].
pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec {
let mut out = Vec::new();
if depth > MAX_DEPTH {
@@ -846,6 +851,8 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize
out
}
+/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows
+/// it), attributes, relatives, methods, instance managers and children by name.
fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
let idx = node_index(&arg(args, 0), ctx)?;
let key = arg(args, 1);
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 75ffae923c..6a38a9743a 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -419,6 +419,8 @@ pub fn formatted_value(picture: &str, raw: &str) -> Option {
(shown != raw).then_some(shown)
}
+/// The native PDF action a button's click script amounts to (a reset, print, save or link),
+/// else the script itself to run; `None` without a click script.
fn button_action(scripts: &[Script]) -> Option {
let s = scripts.iter().find(|s| s.activity == "click")?;
let t = &s.text;
diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs
index c753e5aedb..92e9e5c28a 100644
--- a/crates/xfa/src/parse.rs
+++ b/crates/xfa/src/parse.rs
@@ -407,6 +407,8 @@ fn parse_items(n: XmlNode, warnings: &mut Vec) -> (Vec, Vec` element as the template model: its widget, caption, value, items, pictures,
+/// access and scripts.
fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field {
let ui = child(n, "ui");
let ui_el = ui.and_then(|u| u.children().find(|c| c.is_element() && c.tag_name().name() != "extras" && c.tag_name().name() != "picture"));
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index f75235e200..64d3948983 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -188,6 +188,8 @@ impl Walker<'_> {
true
}
+ /// Note the scripted events of the object at `som`: its event scripts, its calculation and
+ /// its validation (with the message a failing one shows).
fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) {
if self.events.len() >= MAX_EVENTS {
self.truncated = true;
@@ -229,14 +231,18 @@ impl Walker<'_> {
}
}
+ /// The presence of the object at `som`: a script's override, else the template's `p`.
fn presence(&self, som: &str, p: Presence) -> String {
self.ov.presence.get(som).cloned().unwrap_or_else(|| presence_name(p).to_string())
}
+ /// The access of the object at `som`: a script's override, else the template's `a`.
fn access(&self, som: &str, a: Access) -> String {
self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string())
}
+ /// The live node of field `f`, instance `index` under `parent_som`, valued from the data or
+ /// its template default (`radio`: the value its exclusion group chose), with its events noted.
fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode {
let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index);
let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
@@ -277,6 +283,8 @@ impl Walker<'_> {
}
}
+ /// The live nodes of template `nodes` under `parent_som`: as many instances of a subform as
+ /// the data or its occurrence asks for, areas, fields, exclusion groups and draws.
fn children(&mut self, nodes: &[Node], parent_som: &str, depth: usize) -> Vec {
let mut out = Vec::new();
if depth > MAX_DEPTH {
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 963056f10b..22ddb5ed29 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -310,6 +310,7 @@ fn jpeg_headers_give_the_size() {
const DATA: &str = "";
+/// The datasets packet parses into a tree whose nodes the fields' data paths find.
#[test]
fn datasets_parse_into_a_tree_and_som_paths_find_their_nodes() {
let d = parse_datasets(&template_with_data(1, DATA)).expect("datasets");
@@ -323,6 +324,8 @@ fn datasets_parse_into_a_tree_and_som_paths_find_their_nodes() {
assert!(parse_datasets("").is_none());
}
+/// ISO dates convert to and from the date patterns fields show; text that isn't a date is left
+/// alone or refused.
#[test]
fn dates_convert_between_iso_and_acrobat_patterns() {
assert_eq!(iso_to_pattern("2001-02-03", "yyyy-mm-dd"), "2001-02-03");
@@ -362,6 +365,7 @@ fn display_pictures_are_parsed_apart_from_edit_pictures() {
assert_eq!(days.display_picture, None);
}
+/// The datasets fill the fields they bind to and add the rows the data holds.
#[test]
fn data_fills_the_fields_and_adds_rows() {
let form = layout_xml(&template_with_data(1, DATA)).unwrap();
From 21b6267129accfd3ae163eca50dc214f4b17047b Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:57:14 -0400
Subject: [PATCH 43/45] M6: a display picture with month or weekday names
leaves formattedValue raw
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
date{DD MMM YYYY} and date{EEE …} have parts formatted_value can't show
yet, so it gives the raw value instead of text like 20 mmm 2026.
---
crates/xfa/src/data.rs | 2 +-
crates/xfa/src/layout.rs | 12 ++++++++++--
crates/xfa/src/tests.rs | 3 +++
3 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs
index d3e1f2c1ae..fc0997facb 100644
--- a/crates/xfa/src/data.rs
+++ b/crates/xfa/src/data.rs
@@ -239,7 +239,7 @@ fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option {
}
/// The tokens of a date pattern: `yyyy`, `yy`, `mm`, `m`, `dd`, `d`, or a literal character.
-fn date_tokens(pattern: &str) -> Vec {
+pub(crate) fn date_tokens(pattern: &str) -> Vec {
let mut out = Vec::new();
let chars: Vec = pattern.chars().collect();
let mut i = 0;
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index 6a38a9743a..c71f63b402 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -408,14 +408,22 @@ fn date_pattern(picture: Option<&str>) -> String {
/// What scripts read as a field's `formattedValue` when its display picture is `picture`: the
/// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into
/// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a
-/// clause this can't read and for a value that isn't an ISO date: the raw value stands then.
+/// clause this can't read or show (month and weekday names) and for a value that isn't an ISO
+/// date: the raw value stands then.
pub fn formatted_value(picture: &str, raw: &str) -> Option {
let first = picture.split('|').next()?.trim();
let rest = first.strip_prefix("date")?;
if !rest.starts_with(['{', '(']) {
return None;
}
- let shown = iso_to_pattern(raw, &date_pattern(Some(first)));
+ let pattern = date_pattern(Some(first));
+ // A picture with parts this can't show yet (month or weekday names, `MMM` or `EEE`) would
+ // leave them as letters: the raw value stands then.
+ let shows = |t: &String| !t.chars().all(|c| c.is_ascii_alphabetic()) || matches!(t.as_str(), "yyyy" | "yy" | "mm" | "m" | "dd" | "d");
+ if !crate::data::date_tokens(&pattern).iter().all(shows) {
+ return None;
+ }
+ let shown = iso_to_pattern(raw, &pattern);
(shown != raw).then_some(shown)
}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 22ddb5ed29..0a655dd153 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -351,6 +351,9 @@ fn formatted_values_follow_date_display_pictures() {
assert_eq!(crate::formatted_value("num{zzz9}", "2026-10-20"), None);
assert_eq!(crate::formatted_value("date{YYYYMMDD}", "20261020"), None, "not an ISO date");
assert_eq!(crate::formatted_value("date{YYYYMMDD}", ""), None);
+ // Month and weekday names aren't shown yet: the raw value stands rather than `20 mmm 2026`.
+ assert_eq!(crate::formatted_value("date{DD MMM YYYY}", "2026-10-20"), None);
+ assert_eq!(crate::formatted_value("date{EEE MM/DD/YYYY}", "2026-10-20"), None);
}
/// A field's `` is its display picture, kept apart from its `` edit
From 526d87d85c947c1745a351aad75f5173d33db673 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:09:00 -0400
Subject: [PATCH 44/45] M6: formattedValue formats only a value of the ISO
date's own shape
2026-1-2 isn't an ISO date and stands as it is. A test also pins that
parentheses hold a picture's locale (date(en_US){YYYYMMDD} formats) and
that a clause without braces has no pattern.
---
crates/xfa/src/layout.rs | 6 ++++++
crates/xfa/src/tests.rs | 5 +++++
2 files changed, 11 insertions(+)
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index c71f63b402..5a501fbf20 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -423,6 +423,12 @@ pub fn formatted_value(picture: &str, raw: &str) -> Option {
if !crate::data::date_tokens(&pattern).iter().all(shows) {
return None;
}
+ // Only the ISO date's own shape (`2026-10-20`): `2026-1-2` is not one, and stands.
+ let b = raw.as_bytes();
+ let iso = b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() });
+ if !iso {
+ return None;
+ }
let shown = iso_to_pattern(raw, &pattern);
(shown != raw).then_some(shown)
}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 0a655dd153..1babcc62f0 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -354,6 +354,11 @@ fn formatted_values_follow_date_display_pictures() {
// Month and weekday names aren't shown yet: the raw value stands rather than `20 mmm 2026`.
assert_eq!(crate::formatted_value("date{DD MMM YYYY}", "2026-10-20"), None);
assert_eq!(crate::formatted_value("date{EEE MM/DD/YYYY}", "2026-10-20"), None);
+ // Only the ISO date's own shape is formatted.
+ assert_eq!(crate::formatted_value("date{YYYYMMDD}", "2026-1-2"), None);
+ // Parentheses hold the picture's locale and braces its pattern: one without braces has none.
+ assert_eq!(crate::formatted_value("date(en_US){YYYYMMDD}", "2026-10-20").as_deref(), Some("20261020"));
+ assert_eq!(crate::formatted_value("date(YYYYMMDD)", "2026-10-20"), None);
}
/// A field's `` is its display picture, kept apart from its `` edit
From 844e35781a111421b4d12671fd04e908ecb69916 Mon Sep 17 00:00:00 2001
From: aiongg
Date: Thu, 22 Oct 2026 01:47:22 +0000
Subject: [PATCH 45/45] Integration: #2624 doc comment of
xfa_scripts_initialize_calculate_validate_toggle_and_add_rows back on its
test
The #2624 merge inserted its engine tests between #3782's doc comment and the test it documents.
---
crates/engine/src/tests.rs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index cdcdc8fb65..2d60d24911 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -4623,9 +4623,6 @@ fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() {
assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors);
}
-/// A scripted form end to end: initialize and calculate on open, recalculation after a change,
-/// a failing validation's message, buttons that add and remove rows and show messages, a check
-/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all.
#[test]
fn filling_growable_xfa_text_reflows_and_undo_restores_the_layout() {
let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect();
@@ -4736,6 +4733,9 @@ fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() {
s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now");
}
+/// A scripted form end to end: initialize and calculate on open, recalculation after a change,
+/// a failing validation's message, buttons that add and remove rows and show messages, a check
+/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all.
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));