diff --git a/ROADMAP.md b/ROADMAP.md index ae3f678b76..43d8fc907c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,6 +52,9 @@ > **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 (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 (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. 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 @@ -222,6 +225,9 @@ 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 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. 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-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 9208b364d6..5b0ec94ae3 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5879,6 +5879,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"); @@ -6076,6 +6108,65 @@ 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 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 5fa9747b00..ae38e6ae61 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/lib.rs b/crates/engine/src/lib.rs index 65df2f2e32..0cfa463a5f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2524,7 +2524,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()); @@ -3248,6 +3248,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"), } } @@ -3724,6 +3726,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(); @@ -3843,6 +3846,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. @@ -3861,7 +3876,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 62073316ba..766976822e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4437,6 +4437,43 @@ 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() { + // The fixture's own config packet gains `acrobat/acrobat7/dynamicRender`. + let template = pdfcraft_xfa::fixtures::template(1); + let forbidden = "forbidden"; + 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); + let at = bytes.windows(20).position(|w| w == b"/NeedsRendering true").expect("the fixture sets it"); + let mut bytes = bytes; + bytes.splice(at..at + 20, *b" "); + Arc::new(bytes) + }; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let original = shell(&with_config); + let id = s.open("static.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static)); + assert!(doc.xfa.is_none(), "nothing was laid out"); + assert_eq!(doc.bytes[..], original[..], "no revision was appended on open"); + assert_eq!(doc.info.pages.len(), 1); + // The same form without the config is still taken to be dynamic: laid out from the template + // into an appended revision. + let plain = shell(&template); + let id = s.open("dynamic.pdf", None, plain.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert!(doc.xfa.is_some(), "laid out from the template"); + assert!(doc.bytes.len() > plain.len() && doc.bytes.starts_with(&plain[..]), "the layout is a revision"); +} + +/// 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("Ada1")); @@ -4466,6 +4503,56 @@ 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()]); +} + +/// 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(""))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + // Two siblings named `due` (laid out as `due` and `due_2`) and a field bound `YYYYMM`. + for name in ["due", "due_2", "month"] { + s.apply(id, Edit::SetFieldValue { name: name.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(); + let at = |som: &str| data.text_at(&pdfcraft_xfa::som_to_path(som)).map(str::to_string); + assert_eq!(at("form[0].page[0].due[0]").as_deref(), Some("20261018"), "the first sibling's data picture"); + assert_eq!(at("form[0].page[0].due[1]").as_deref(), Some("18102026"), "the second sibling's own"); + // A data picture without a day would drop the one the field shows: the data keeps ISO. + assert_eq!(at("form[0].page[0].month[0]").as_deref(), Some("2026-10-18")); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + for name in ["due", "due_2", "month"] { + let f = d2.form.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")); + assert_eq!(f.value, vec!["10/18/2026".to_string()], "{name} reads back"); + } +} + +/// 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. @@ -4561,6 +4648,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; @@ -4619,6 +4708,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. @@ -4642,6 +4736,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("Ada1")); @@ -4662,6 +4758,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())); @@ -4694,6 +4792,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); @@ -4724,11 +4824,15 @@ 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 - // reads `a`. A failed script's half-done changes reach nobody. And `d` sets a choice list - // by its shown text: the next script reads the saved value the engine kept. + // reads `a`. A failed script keeps what it changed before the error, and the next script + // sees it. And `d` sets a choice list by its shown text: the next script reads the saved + // value the engine kept. let tpl = pdfcraft_xfa::fixtures::fields_template("
JP
").replace( r#""#, r#"1 @@ -4744,13 +4848,153 @@ fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() { 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(); let out = s.take_js_output(id); assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}"); - assert_eq!(value(&s, "a"), vec!["1".to_string()], "the failed script's write reached nothing"); - assert_eq!(value(&s, "c"), vec!["2".to_string()]); + assert_eq!(value(&s, "a"), vec!["99".to_string()], "the failed script's write before the error stands"); + assert_eq!(value(&s, "c"), vec!["100".to_string()]); assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value"); assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value"); 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 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_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]); + } +} + +/// A script that fails partway keeps what it did before the error, as JavaScript does: a form +/// whose check box opens the next boxes and then trips over an object the form no longer has +/// still opens them (DA Form 31's leave types). +#[test] +fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::partly_failing_script_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned(); + assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 != 0), "protected until `kind` is checked"); + assert!(field(&s, "details").is_none(), "invisible: not laid out"); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "kind".into(), value: FieldValue::Check(true) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|e| e.contains("kind") && e.contains("change")), "the error is still reported: {out:?}"); + assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 == 0), "opened before the error"); + assert!(field(&s, "details").is_some(), "shown 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 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..6ce76548f5 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(), @@ -149,6 +150,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)), } } @@ -669,9 +671,12 @@ impl<'a, 'b> Runner<'a, 'b> { for n in &o.notes { self.error(format!("{} ({}): {n}", ev.som, ev.activity)); } + // A script that fails keeps what it changed before the error, as JavaScript does: forms + // often trip over an object an earlier version had, late in a script whose first lines + // did the work (opening the next fields, showing a subform). An abandoned script, or + // one the engine failed, reports no changes. if let Some(e) = &o.error { self.error(format!("{} ({}): {e}", ev.som, ev.activity)); - return Ok(Some(o)); } if self.apply_effects(ev, &o.effects)? { self.relayout()?; @@ -764,6 +769,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); @@ -826,10 +874,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/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 1ab7ca24e5..85d6f90ea0 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1097,10 +1097,16 @@ 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 { - "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 f6bbdb3523..6baa59e5a8 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"); @@ -395,6 +398,39 @@ 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")); + } + + /// 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. @@ -607,6 +643,22 @@ 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")); + } + + /// 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 a2361da7e4..5a693db9f0 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; @@ -357,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; @@ -381,6 +390,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 +628,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(), ) @@ -817,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 { @@ -837,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); @@ -853,7 +869,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/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index c2c5ee3cc3..91447454dd 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -1,8 +1,9 @@ //! One script engine for a whole form event. Every script of the event runs on one thread //! (with the script stack), each in a fresh JavaScript context on a fresh copy of the form as //! the caller holds it, so scripts can't see each other's variables or objects and a script -//! that failed halfway leaves nothing behind; what a script changed reaches the next one only -//! through the caller, which applies the effects and sends the form as it then is. Before, +//! that failed halfway leaves no state behind in the engine; what a script changed (up to its +//! error, when it failed) reaches the next one only through the caller, which applies the +//! effects and sends the form as it then is. Before, //! every script also got a thread of its own and the caller rebuilt the form for each: a form //! with hundreds of calculations spent its open-time budget on that. //! diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 6bcceca032..402dadae18 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; @@ -90,8 +94,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, } @@ -700,8 +704,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); @@ -772,6 +780,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() + }; + xfa_config_dynamic_render(&xfa_packet_text(&bytes)) + } + fn resolve(&self, o: &'a Object) -> &'a Object { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } @@ -1668,6 +1698,116 @@ 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)))) } +/// 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; + } + if rest.starts_with("")?; + continue; + } + if 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 }); + } +} + +/// 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; + // 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 { + 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 open_tags.is_empty() && tag.local == name { + open = Some(tag.end); + } + open_tags.push(tag.local); + } else if open_tags.is_empty() && tag.local == name { + return None; + } + } +} + +/// 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_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] @@ -2709,6 +2849,101 @@ trailer << /Root 1 0 R >> assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); } + #[test] + fn xfa_dynamic_render_is_read_from_config_acrobat7_only() { + let text = xfa_config_dynamic_render; + 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); + // 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 = "forbiddenforbidden"; + 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")); + // 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() { + assert_eq!(text(&whole[..end]), None, "cut at {end}"); + } + 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 + /// 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