From c392d93ba6f864dd3e35e91176ba4aba29b5afa1 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:06:32 -0400 Subject: [PATCH 1/2] M12: laying out a dynamic XFA form takes off the usage rights it voids The layout revision replaces the placeholder pages and adds the fields, so usage rights (/Perms /UR3) signed over the form as published no longer match it, and a viewer may then open the form with every field locked. write_form now takes them off with the helper strip already needed, and the layout report says so. --- crates/engine/src/tests.rs | 26 +++++++++++++++ crates/ui-egui/tests/ui_group/xfa.rs | 8 +++++ crates/xfa/src/fixtures.rs | 21 ++++++++++++ crates/xfa/src/lib.rs | 3 ++ crates/xfa/src/pdf.rs | 50 ++++++++++++++++++---------- crates/xfa/src/script.rs | 3 ++ crates/xfa/src/tests.rs | 31 +++++++++++++++++ parity/acrobat-features.toml | 6 ++-- 8 files changed, 128 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41da..c89c5aac7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4071,6 +4071,32 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +#[test] +fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { + let xdp = pdfcraft_xfa::fixtures::template(3); + let original = Arc::new(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let warnings = &doc.xfa.as_ref().expect("laid out from the template").warnings; + assert!(warnings.iter().any(|w| w.contains("usage rights")), "the user is told: {warnings:?}"); + assert!(!doc.dirty, "laying out is still not an edit"); + s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap(); + let incremental = s.save_bytes(id).unwrap(); + assert!(incremental.starts_with(&original[..]), "still an incremental save"); + // Saved either way, the file no longer carries them, and keeps what was filled in. + for saved in [incremental, s.save_full_bytes(id).unwrap()] { + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let catalog = cos.get(cos.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Perms"), "{catalog:?}"); + let again = s.open("saved.pdf", None, saved, None).unwrap(); + let d2 = s.get(again).unwrap(); + assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]); + let warnings = &d2.xfa.as_ref().expect("the layout is remembered").warnings; + assert!(!warnings.iter().any(|w| w.contains("usage rights")), "nothing left to say: {warnings:?}"); + } +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 2285f36e0..327b2368d 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -62,6 +62,14 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { h.get_by_label("Highlight fields"); } +#[test] +fn a_dynamic_xfa_form_says_when_laying_it_out_took_its_usage_rights_off() { + let xdp = pdfcraft_xfa::fixtures::template(2); + let h = open(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields"); + h.get_by_label_contains("usage rights were removed"); +} + #[test] fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() { // No fields, the form lives in the XFA packets (an array of name/stream pairs here), but diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 54bb9bc21..e3ab415e6 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -364,6 +364,27 @@ pub fn shell(xdp: &str) -> Vec { assemble(objs) } +/// [`shell`] with usage rights: the catalog's `/Perms` holds a `/UR3` signature (a stand-in +/// dictionary, nothing verifies it), beside `others` (`/DocMDP 6 0 R`, say: object 6 is a +/// signature dictionary too). `indirect` puts `/Perms` in an object of its own. +pub fn usage_rights_shell(xdp: &str, others: &str, indirect: bool) -> Vec { + let perms = format!("<< /UR3 6 0 R {others} >>"); + let entry = if indirect { "7 0 R".to_string() } else { perms.clone() }; + let catalog = format!("<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Perms {entry} >>"); + let mut objs: Vec> = vec![ + catalog.into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(), + b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /Type /Sig /Reference [<< /Type /SigRef /TransformMethod /UR3 >>] >>".to_vec(), + ]; + if indirect { + objs.push(perms.into_bytes()); + } + assemble(objs) +} + /// A dynamic form with two signature boxes of which `sign` has been signed: the file holds the /// field a signing application leaves (named by its SOM path, its value a signature dictionary) /// and the picture drawn in the box, a solid blue rectangle. diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index bf4414970..8711df9fe 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -165,6 +165,9 @@ pub fn render_into(doc: &mut Document) -> Result { let written = pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index cca62e64e..a84ed9c42 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -150,10 +150,16 @@ fn js_action(js: &str) -> Object { Object::Dict(a) } +/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). +pub(crate) const USAGE_RIGHTS_REMOVED: &str = + "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; + pub struct Written { pub pages: usize, pub fields: usize, pub warnings: Vec, + /// Usage rights were taken off the catalog (laying the form out voided them). + pub usage_rights: bool, } /// On a push button generated for an XFA image field: clicking it picks a picture (the forms @@ -774,10 +780,12 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result catalog_changed = true; } } - if catalog_changed { + // The pages and fields are new: usage rights signed over the form as it was are void now. + let usage_rights = drop_usage_rights(doc, &mut catalog); + if catalog_changed || usage_rights { doc.set(catalog_ref, Object::Dict(catalog)); } - Ok(Written { pages: count, fields: field_count, warnings }) + Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } /// What [`strip`] took out, for the user to be told (the packets held more than the fields). @@ -963,21 +971,7 @@ pub fn strip(doc: &mut Document) -> Result { None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), } catalog.remove(b"NeedsRendering"); - // Reader usage rights are void once the form changed: Reader says so on opening the file - // and drops them, so they go now, with a note. - if let Some(perms) = catalog.get(b"Perms").map(|p| doc.resolve(p)) - && let Some(mut perms) = perms.as_dict().cloned() - && (perms.contains(b"UR3") || perms.contains(b"UR")) - { - perms.remove(b"UR3"); - perms.remove(b"UR"); - out.usage_rights = true; - if perms.is_empty() { - catalog.remove(b"Perms"); - } else { - catalog.set(b"Perms".to_vec(), Object::Dict(perms)); - } - } + out.usage_rights = drop_usage_rights(doc, &mut catalog); doc.set(root, Object::Dict(catalog)); // The packet streams are referenced from nowhere else: freed, so an incremental save // doesn't carry an unreachable copy of the data. @@ -1024,6 +1018,28 @@ fn unbound_data(doc: &Document) -> Option { Some(count) } +/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of +/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its +/// pages or fields change they are void, and a viewer may lock every field of a form whose +/// usage rights are void, so they go, and the caller says so. Whether there were any. +fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { + let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; + let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; + if !perms.contains(b"UR3") && !perms.contains(b"UR") { + return false; + } + perms.remove(b"UR3"); + perms.remove(b"UR"); + match entry.as_ref() { + _ if perms.is_empty() => { + catalog.remove(b"Perms"); + } + Some(r) => doc.set(r, Object::Dict(perms)), + None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), + } + true +} + /// The layout a document already carries from an earlier pass, if any. pub fn existing_layout(doc: &Document) -> Option { let root = doc.root()?; diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 17a037664..a3ac812eb 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -576,6 +576,9 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result let written = crate::pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67a..3dd84222c 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1961,6 +1961,37 @@ fn an_anchor_type_places_the_named_point_of_the_box_at_its_x_and_y() { assert!((field.rect.x - 6.0 * 72.0).abs() < 0.01 && (field.rect.y - 72.0).abs() < 0.01, "{:?}", field.rect); } +#[test] +fn laying_a_form_out_takes_off_the_usage_rights_it_voids() { + use crate::fixtures::usage_rights_shell; + let xdp = fields_template("
JP
"); + // What `/Perms` holds once the layout is saved and the file read again. + let laid_out = |bytes: Vec| { + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let report = render_into(&mut doc).unwrap(); + let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let indirect = catalog.get(b"Perms").and_then(Object::as_ref).is_some(); + let perms = catalog.get(b"Perms").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()); + let said = report.warnings.iter().any(|w| w.contains("usage rights")); + (perms, indirect, said, report.warnings) + }; + // Usage rights alone: `/Perms` goes, and the user is told. + let (perms, _, said, warnings) = laid_out(usage_rights_shell(&xdp, "", false)); + assert!(perms.is_none(), "{perms:?}"); + assert!(said, "{warnings:?}"); + // Beside a certification, in an object of its own: only the usage rights go, and the + // object is rewritten where it is. + let (perms, indirect, said, warnings) = laid_out(usage_rights_shell(&xdp, "/DocMDP 6 0 R", true)); + let perms = perms.expect("the certification stays"); + assert!(perms.contains(b"DocMDP") && !perms.contains(b"UR3"), "{perms:?}"); + assert!(indirect, "/Perms is still its own object"); + assert!(said, "{warnings:?}"); + // A form without usage rights says nothing about them. + let (perms, _, said, warnings) = laid_out(shell(&xdp)); + assert!(perms.is_none() && !said, "{perms:?} {warnings:?}"); +} + #[test] fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { use crate::pdf::strip; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b1805d522..5f9fc9b9d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -707,7 +707,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "preserve only" +notes = "preserve only. A dynamic XFA form PdfCraft lays out loses them: the layout voids them, so they are taken off with a note (form.xfa-dynamic)" # ================================================================================================== # B. Viewing and navigation @@ -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/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided"] +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; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; 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" From 93544ef1bff769b2b722d43bbdc024e4484304b5 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:32:52 -0400 Subject: [PATCH 2/2] M12: the XFA notice bar starts its first layout note as a sentence Layout notes are lowercase clauses joined with semicolons, and the bar put the first right after the layout's own sentence ('11 fields. the form's usage rights...'). The first note now starts with a capital. --- crates/ui-egui/src/canvas.rs | 6 +++++- crates/ui-egui/tests/ui_group/xfa.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad7483465..1a412ff63 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4494,8 +4494,12 @@ fn notices( let mut text = crate::i18n::fmt(tl!("Dynamic XFA form laid out from its template: {pages}, {fields}."), &[("pages", &pages), ("fields", &fields)]); if !x.warnings.is_empty() { + // The notes are clauses ("a picture is left out"); the first starts a sentence here. + let notes = x.warnings.join("; "); + let mut chars = notes.chars(); text.push(' '); - text.push_str(&x.warnings.join("; ")); + text.extend(chars.next().into_iter().flat_map(char::to_uppercase)); + text.push_str(chars.as_str()); text.push('.'); } Some(("text-cursor-input", text, true)) diff --git a/crates/ui-egui/tests/ui_group/xfa.rs b/crates/ui-egui/tests/ui_group/xfa.rs index 327b2368d..34bc52386 100644 --- a/crates/ui-egui/tests/ui_group/xfa.rs +++ b/crates/ui-egui/tests/ui_group/xfa.rs @@ -66,8 +66,8 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { fn a_dynamic_xfa_form_says_when_laying_it_out_took_its_usage_rights_off() { let xdp = pdfcraft_xfa::fixtures::template(2); let h = open(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); - h.get_by_label_contains("laid out from its template: 2 pages, 11 fields"); - h.get_by_label_contains("usage rights were removed"); + // The note starts its own sentence after the layout's. + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields. The form's usage rights were removed"); } #[test]