diff --git a/ROADMAP.md b/ROADMAP.md index 95230d1dd..231ad9fec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,6 +43,7 @@ > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. 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) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. 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 @@ -193,6 +194,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. - **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-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged. +- **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. 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/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 724ffaf27..1d1ab5ce4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5593,6 +5593,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 b6bfd4758..7616625f3 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -24,6 +24,7 @@ that warning and keeps the existing layout and accepted widget values. 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 b9425df14..fc9ad1a24 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -455,8 +455,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 a40798015..1d6efc152 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -188,6 +188,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