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 1/3] 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 fe9723fdf..c97889952 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 1e662d8f3..93b1d417a 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, "
Old
") + .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 5d494d7b4..db5c4cb28 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 f6c69789b..7a894ab49 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 1e8730586..72c58abb1 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