From bc5aff5ff6fd01ef4fa1125271b51afe184303c3 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:23:20 +0800 Subject: [PATCH 1/7] M12: lay a dynamic XFA form out once after its open-time scripts On opening, the engine laid the form out again after every initialize or calculate script that changed presence, access or instances. On a large form one layout takes about the whole 3 s open budget, so the remaining initialize scripts were skipped, and that half-initialized form was what saving kept: each later opening hid more of it, and a page shown at the first opening, filled in, was hidden and its data reset by the next one. The scripts read the live form (template, data and overrides), not the pages, and XFA 3.3 fires initialize after data binding, before layout (pp. 388, 390, 407). On opening, a needed layout is now recorded and done once in finish(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/engine/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++++ crates/engine/src/xfa.rs | 22 +++++++++++++++-- parity/acrobat-features.toml | 4 +-- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 333d0fd498..ce5b221834 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6529,6 +6529,53 @@ fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() { assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started"); } +/// Opening lays the form out once after its initialize scripts, not after each one that +/// hides a subform (XFA 3.3 pp. 388, 390: initialize fires once data binding is complete; +/// the layout process has not run yet). Laying it out after every script spent the opening's +/// time on the first few, and a form whose initialize scripts hide its optional pages was +/// saved half initialized: the next opening hid more of it, and reset the data typed into +/// what it hid. +#[test] +fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() { + // Shaped like a form with optional extra pages: a check box, then more sections than one + // event may lay the form out again for, each hiding itself while the box is clear. + let sections = 60; + let mut body = String::from( + r#"100"#, + ); + for n in 0..sections { + body.push_str(&format!( + r#""# + )); + } + let tpl = format!( + r#" + + + +"# + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let id = s.open("extras.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let names = |s: &Session, id| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + let opened = names(&s, id); + assert!(opened.iter().all(|n| !n.contains("note")), "every extra section is hidden on opening: {opened:?}"); + assert!(opened.iter().any(|n| n.contains("wanted")), "{opened:?}"); + // The saved form opens as it was saved. + let saved = s.save_bytes(id).unwrap(); + let again = s.open("again.pdf", None, saved, None).expect("reopens"); + assert!(s.take_js_output(again).errors.is_empty()); + assert_eq!(names(&s, again), opened); +} + /// 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. diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index f20c82f6c6..55e31e187f 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -256,6 +256,11 @@ struct Runner<'a, 'b> { skipped: usize, relayouts: usize, relayouts_skipped: bool, + /// Lay the form out once, when the event ends, rather than after every script that changed + /// its structure (opening: see [`on_open`]). + defer_relayout: bool, + /// A deferred layout is owed. + relayout_owed: bool, dropped_output: usize, truncated_warned: bool, data: Option, @@ -291,6 +296,8 @@ impl<'a, 'b> Runner<'a, 'b> { skipped: 0, relayouts: 0, relayouts_skipped: false, + defer_relayout: false, + relayout_owed: false, dropped_output: 0, truncated_warned: false, data, @@ -689,7 +696,11 @@ impl<'a, 'b> Runner<'a, 'b> { self.error(format!("{} ({}): {e}", ev.som, ev.activity)); } if self.apply_effects(ev, &o.effects)? { - self.relayout()?; + if self.defer_relayout { + self.relayout_owed = true; + } else { + self.relayout()?; + } } Ok(Some(o)) } @@ -719,9 +730,10 @@ impl<'a, 'b> Runner<'a, 'b> { fn finish(mut self) -> Result { #[cfg(test)] self.check_live(); - if self.relayouts_skipped { + if self.relayouts_skipped || self.relayout_owed { self.relayouts = 0; self.relayouts_skipped = false; + self.relayout_owed = false; self.relayout()?; } self.flush()?; @@ -825,6 +837,12 @@ pub(crate) fn relayout_changed_text( /// 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); + // The scripts read the live form (template, data and overrides), not the pages, so the + // pages are laid out once, after the last of them. Laying a large form out after every + // script that hides a subform spent the time limit on the first few, and the form was kept + // half initialized: a page its initialize scripts hide stayed open to typing until the + // next opening hid it and reset its data. + r.defer_relayout = true; let events: Vec = r.live().events.iter().filter(|e| e.activity == "initialize" || e.activity == "docReady").cloned().collect(); for ev in &events { r.run(ev, "")?; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..bcc2b29e70 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5320,8 +5320,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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/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/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "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", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." +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/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/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/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/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/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once", "crates/engine/src/tests.rs::xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else", "crates/js/src/tests.rs::hosts_given_the_same_form_reuse_its_flattened_copy_and_change_only_their_own", "crates/js/src/tests.rs::a_form_sent_again_does_not_carry_the_last_scripts_changes", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "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", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), 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, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed, and each script starting from a copy of one flattened form, shared until the form changes; resets in a row applied together), so hundreds of calculations, and a 1,300-script form's initialize scripts, 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. Opening lays the form out once, after its initialize and calculate scripts (which read the live form, not the pages), so a form whose initialize scripts hide many sections is initialized within the open budget and opens again as it was saved; a large form can still run out of that budget, and what it skips is reported. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc" From d65c465b1ba227ad36bc7e302c8dc23a02c1ea3e Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:42:13 +0800 Subject: [PATCH 2/7] M12: XFA open-time scripts start from one shared copy of the form On a large dynamic XFA form, every script of an event copied the whole form twice before it ran (the engine's live form to the script engine's tree, then that tree to the flattened form the host works on), and every resetData call rewrote and rescanned the whole document. On a 1,300-script form opening spent its 3 s budget on these copies and on twenty resets, and skipped the last 850 initialize and calculate scripts. The engine now sends the scripts of an event one shared tree (Arc) until the live form changes in any way, and the script thread keeps the flattened form it built from that tree; each script starts from a copy whose text is shared (Arc), so it costs a fraction of a flatten. Each script still gets its own copy: nothing one script changes reaches the next except through the engine. Resets in a row in one script's effects are applied as one reset of the names together. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/engine/src/tests.rs | 51 ++++++++++++++++ crates/engine/src/xfa.rs | 51 +++++++++++++++- crates/js/src/formcalc.rs | 9 +-- crates/js/src/formcalc/access.rs | 12 ++-- crates/js/src/formcalc/eval.rs | 6 +- crates/js/src/tests.rs | 48 ++++++++++++++- crates/js/src/xfa.rs | 2 + crates/js/src/xfa/globals.rs | 6 +- crates/js/src/xfa/host.rs | 102 ++++++++++++++++++++++++------- crates/js/src/xfa/instances.rs | 28 +++++---- crates/js/src/xfa/nodes.rs | 12 ++-- crates/js/src/xfa/run.rs | 20 ++++-- crates/js/src/xfa_session.rs | 18 ++++-- 13 files changed, 294 insertions(+), 71 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ce5b221834..ed53ad2a50 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6576,6 +6576,57 @@ fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() { assert_eq!(names(&s, again), opened); } +/// A script that resets several subforms one after another (`xfa.host.resetData` once for +/// each, as forms with optional pages do) resets every one of them, and nothing else; resets +/// in a row are applied together. +#[test] +fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() { + let sections = 5; + let mut body = String::from( + r#"100"#, + ); + let resets: String = (0..sections).map(|n| format!("xfa.host.resetData('form.extra{n}');")).collect(); + body.push_str(&format!( + r#""# + )); + body.push_str(r#""#); + for n in 0..sections { + body.push_str(&format!( + r#"default"# + )); + } + let notes: String = (0..sections).map(|n| format!("typed {n}")).collect(); + let tpl = format!( + r#" + + +
typed{notes}
+
+"# + ); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + // Without the resets the sections show what was typed. + let untouched = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(&resets, ""))); + let plain = s.open("plain.pdf", None, untouched, None).expect("opens"); + let typed: Vec<_> = s.get(plain).unwrap().form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect(); + assert_eq!(typed, (0..sections).map(|n| vec![format!("typed {n}")]).collect::>()); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let id = s.open("resets.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let form = &s.get(id).unwrap().form; + let notes: Vec<_> = form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect(); + assert_eq!(notes.len(), sections, "{form:?}"); + assert!(notes.iter().all(|v| v == &vec!["default".to_string()]), "every section is reset: {notes:?}"); + let keep = form.iter().find(|f| f.name.contains("keep")).map(|f| f.value.clone()); + assert_eq!(keep, Some(vec!["kept".to_string()]), "the script's later write stands"); +} + /// 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. diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 55e31e187f..ae56646986 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -159,6 +159,23 @@ fn to_js(n: &FormNode) -> XfaNode { } } +/// Add the names of a `resetData` call to the run of resets before it. No names means the +/// whole form, which takes in every other. +fn merge_reset(merged: &mut Vec, names: &[String]) { + if merged.is_empty() { + return; + } + if names.is_empty() { + merged.clear(); + return; + } + for n in names { + if !merged.contains(n) { + merged.push(n.clone()); + } + } +} + fn find_container<'a>(n: &'a FormNode, som: &str, depth: usize) -> Option<&'a FormNode> { if depth > 64 { return None; @@ -275,6 +292,9 @@ struct Runner<'a, 'b> { fields: Option>, /// The live form as last built; dropped when a script changed the form. cached: Option, + /// `cached` as the scripts get it, made once and sent to each script until the live form + /// changes in any way (a value too): the script engine reuses what it built from it. + js_root: Option>, /// The script engine for this event: one thread for all its scripts, each run in a fresh /// context on the form as it is then (`cached`, kept up to date in place for values and /// rebuilt when the structure changed). @@ -309,6 +329,7 @@ impl<'a, 'b> Runner<'a, 'b> { by_path: None, fields: None, cached: None, + js_root: None, session: None, changes: Changes::default(), } @@ -318,6 +339,7 @@ impl<'a, 'b> Runner<'a, 'b> { /// script. fn invalidate(&mut self) { self.cached = None; + self.js_root = None; } /// The script engine for this event. @@ -334,6 +356,8 @@ impl<'a, 'b> Runner<'a, 'b> { let op = pdfcraft_xfa::DataOp::Value { path, text }; self.data.get_or_insert_with(Default::default).apply(&op); self.pending.push(op); + // The value changes in the live form below: the scripts' copy is made again. + self.js_root = None; let refreshed = match self.cached.as_mut() { Some(live) => pdfcraft_xfa::refresh_value(&mut live.root, self.tpl, self.data.as_ref(), som).is_some(), None => true, @@ -591,7 +615,9 @@ impl<'a, 'b> Runner<'a, 'b> { } } } - for e in effects { + // Resets in a row, applied together when the run of them ends. + let mut pending_reset: Option> = None; + for (at, e) in effects.iter().enumerate() { // Only a click script may print, save, open links or move the focus (not the // calculations a click sets off, nor anything run on open or on a change). if let Some(what) = click_only(e) @@ -640,7 +666,19 @@ impl<'a, 'b> Runner<'a, 'b> { relayout = true; } XfaEffect::MessageBox(m) => self.alert(m.clone()), - XfaEffect::ResetData(names) => self.reset(names)?, + XfaEffect::ResetData(names) => { + // A script resetting several subforms in a row (`resetData` once for each) + // resets them together: the same fields end up at their defaults. + if let Some(merged) = pending_reset.as_mut() { + merge_reset(merged, names); + } else { + pending_reset = Some(names.clone()); + } + let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_))); + if !next_is_reset && let Some(names) = pending_reset.take() { + self.reset(&names)?; + } + } XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), @@ -669,7 +707,14 @@ impl<'a, 'b> Runner<'a, 'b> { #[cfg(test)] self.check_live(); let event = XfaEvent { activity: ev.activity.clone(), target: ev.som.clone(), new_text: new_text.to_string(), prev_text: String::new() }; - let root = to_js(&self.live().root); + let root = match &self.js_root { + Some(r) => Arc::clone(r), + None => { + let r = Arc::new(to_js(&self.live().root)); + self.js_root = Some(Arc::clone(&r)); + r + } + }; let script = Script { source: ev.script.clone(), formcalc: ev.formcalc, diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 3e68ce081d..ff43bf0837 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -109,7 +109,7 @@ type R = Result; /// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a /// JavaScript one. pub fn run_formcalc(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { - run_formcalc_at(script, event, doc, root.clone(), limits, None) + run_formcalc_at(script, event, doc, std::sync::Arc::new(root.clone()), false, limits, None) } /// [`run_formcalc`] on an owned form, giving up at `deadline` (the clock is read every @@ -118,7 +118,8 @@ pub(crate) fn run_formcalc_at( script: &str, event: &XfaEvent, doc: &XfaDoc, - root: XfaNode, + root: std::sync::Arc, + reuse: bool, limits: Limits, deadline: Option, ) -> XfaOutcome { @@ -133,7 +134,7 @@ pub(crate) fn run_formcalc_at( Ok(b) => b, Err(e) => return XfaOutcome { error: Some(e), ..Default::default() }, }; - let mut host = XHost::new(root, &event.target, doc); + let mut host = XHost::new(root, reuse, &event.target, doc); #[cfg(target_arch = "wasm32")] let _ = deadline; let mut interp = Interp { @@ -177,7 +178,7 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X // FormCalc has no engine to start: its time (and its own deadline) begins at once. run_within("pdfcraft-formcalc", timeout, move |started| { started(); - run_formcalc_at(&script, &event, &doc, root, limits, Some(timeout)) + run_formcalc_at(&script, &event, &doc, std::sync::Arc::new(root), false, limits, Some(timeout)) }) } diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index bea902c752..5f279ea4f6 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -313,16 +313,16 @@ impl Interp<'_> { let Some(n) = self.node(i) else { return Ok(Val::Null) }; Ok(match name { "formattedValue" => match &n.formatted { - Some(f) => Val::Str(f.clone()), + Some(f) => Val::Str(f.to_string()), None => self.node_value(i), }, "rawValue" | "value" => self.node_value(i), - "name" => Val::Str(n.name.clone()), - "somExpression" => Val::Str(n.som.clone()), + "name" => Val::Str(n.name.to_string()), + "somExpression" => Val::Str(n.som.to_string()), "className" => Val::Str(n.kind.class_name().into()), "index" | "instanceIndex" => Val::Num(n.index as f64), - "presence" => Val::Str(n.presence.clone()), - "access" => Val::Str(n.access.clone()), + "presence" => Val::Str(n.presence.to_string()), + "access" => Val::Str(n.access.to_string()), "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), "mandatory" => Val::Str("disabled".into()), "parent" => n.parent.map_or(Val::Null, Val::Node), @@ -332,7 +332,7 @@ impl Interp<'_> { None => Val::Node(i), }, "instanceManager" => match n.parent { - Some(p) => Val::Im(p, n.name.clone()), + Some(p) => Val::Im(p, n.name.to_string()), None => Val::Null, }, "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index 0b2dfe4b94..216e26cba5 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -277,11 +277,11 @@ impl Interp<'_> { Some(e) => { let raw = self.eval(e)?; match self.nodes_of(&raw).first() { - Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), + Some(i) => self.node(*i).map(|n| n.som.to_string()).unwrap_or_default(), None => { let s = self.to_str(&raw); let found = resolve_som(self.h, self.h.current, &s); - found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) + found.first().and_then(|i| self.node(*i)).map(|n| n.som.to_string()).unwrap_or(s) } } } @@ -396,7 +396,7 @@ impl Interp<'_> { match target { Val::Node(i) => { let Some(n) = self.node(i) else { return Ok(()) }; - let som = n.som.clone(); + let som = n.som.to_string(); match prop { "rawValue" | "value" | "formattedValue" => { let text = self.to_str(&v); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 2d3877efe3..c933097330 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1542,7 +1542,8 @@ $host.gotoURL("https://example.org")"#, "for i = 1 upto 100000 do Eval(\"1\") endfor", &ev, &doc, - form(), + std::sync::Arc::new(form()), + false, Limits { loop_iterations: u64::MAX, recursion: 32 }, Some(std::time::Duration::ZERO), ); @@ -1660,12 +1661,53 @@ mod session { source: source.into(), formcalc, event: XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }, - root: form(), + root: std::sync::Arc::new(form()), limits: Limits::default(), timeout: std::time::Duration::from_secs(3), } } + /// The scripts of an event given the same form start from one flattened copy of it: the + /// second host shares the first one's text, and each host changes only its own copy. + #[test] + fn hosts_given_the_same_form_reuse_its_flattened_copy_and_change_only_their_own() { + let root = std::sync::Arc::new(form()); + let doc = XfaDoc::default(); + let mut first = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc); + let second = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc); + let shared = std::sync::Arc::clone(&first.nodes.get(1).expect("form1").som); + assert!(std::sync::Arc::ptr_eq(&shared, &second.nodes.get(1).expect("form1").som), "the second host starts from the first one's copy"); + let qty = first.current; + let before = second.nodes.get(qty).map(|n| n.value.clone()); + first.set_state(qty, XfaEffect::SetPresence { som: "form1[0].page1[0].qty[0]".into(), presence: "hidden".into() }); + if let Some(n) = first.nodes.get_mut(qty) { + n.value = "99".into(); + } + let third = crate::xfa::XHost::new(std::sync::Arc::clone(&root), true, "form1[0].page1[0].qty[0]", &doc); + for h in [&second, &third] { + assert_eq!(h.nodes.get(qty).map(|n| n.value.clone()), before); + assert_eq!(h.nodes.get(qty).map(|n| n.presence.to_string()).as_deref(), Some("visible")); + } + // Another form (a new `Arc`, even with the same content) is flattened again. + let other = crate::xfa::XHost::new(std::sync::Arc::new(form()), true, "form1[0].page1[0].qty[0]", &doc); + assert!(!std::sync::Arc::ptr_eq(&other.nodes.get(1).expect("form1").som, &shared)); + } + + /// One session given the same form for two scripts: what the first changed on its copy is + /// not in the second's. + #[test] + fn a_form_sent_again_does_not_carry_the_last_scripts_changes() { + let mut s = XfaSession::new(XfaDoc::default()); + let root = std::sync::Arc::new(form()); + let mut a = script("qty.rawValue = 10; details.presence = 'visible'; qty.rawValue", false, "form1[0].page1[0].total[0]", "calculate"); + a.root = std::sync::Arc::clone(&root); + assert_eq!(s.run(a).result.as_deref(), Some("10")); + let mut b = script("qty.rawValue + ':' + details.presence", false, "form1[0].page1[0].total[0]", "calculate"); + b.root = std::sync::Arc::clone(&root); + let o = s.run(b); + assert_eq!(o.result.as_deref(), Some("4:hidden"), "{o:?}"); + } + /// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller /// sends: nothing an earlier script changed or defined leaks into the next, a failing script /// doesn't end the session, and hundreds of scripts run on its one thread. @@ -1683,7 +1725,7 @@ mod session { let o = s.run(script("qty * price", true, "form1[0].page1[0].total[0]", "calculate")); assert_eq!(o.result.as_deref(), Some("10"), "{o:?}"); let mut with = script("qty * price", true, "form1[0].page1[0].total[0]", "calculate"); - with.root.children[0].children[0].value = "10".into(); + std::sync::Arc::make_mut(&mut with.root).children[0].children[0].value = "10".into(); assert_eq!(s.run(with).result.as_deref(), Some("25")); // Nor do variables, globals, prototype changes or kept objects. s.run(script("leak = 5; String.prototype.trim = function () { return 'P' }; kept = qty; 1", false, "form1[0].page1[0].qty[0]", "calculate")); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index eb275d1822..c623d0766f 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -24,6 +24,8 @@ mod model; mod nodes; mod run; +#[cfg(target_arch = "wasm32")] +pub(crate) use host::forget_base; pub(crate) use host::{HNode, XHost}; pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome}; pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names}; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 971cf12fc1..d166b7147b 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -69,13 +69,13 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let i = node_index(&target, ctx)?; - host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() + host(ctx)?.borrow().nodes.get(i).map(|n| n.som.to_string()).unwrap_or_default() } _ => { let expr = text(&target, ctx)?; let h = host(ctx)?.borrow(); let found = resolve_som(&h, h.current, &expr); - found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) + found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.to_string()).unwrap_or(expr) } }; effect(ctx, XfaEffect::SetFocus(som))?; @@ -329,7 +329,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current let h = host(ctx)?.borrow(); h.nodes .get(scope) - .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) + .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.to_string(), k.repeatable && !k.gone))).collect()) .unwrap_or_default() }; for (c, name, repeatable) in kids { diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs index ec222a4ca8..ffc8352005 100644 --- a/crates/js/src/xfa/host.rs +++ b/crates/js/src/xfa/host.rs @@ -2,27 +2,71 @@ use super::model::{ MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, effect_key, payload, }; +use std::sync::Arc; + use super::{Context, HashMap, JsResult, Message, RefCell, error}; -/// One node of the flattened form, shared by the JavaScript and FormCalc engines. +/// One node of the flattened form, shared by the JavaScript and FormCalc engines. Its text is +/// shared (`Arc`), so the copy each script starts from costs little: the scripts of one +/// event start from the same flattened form until it changes (see [`XHost::new`]). +#[derive(Clone)] pub(crate) struct HNode { - pub(crate) name: String, - pub(crate) som: String, + pub(crate) name: Arc, + pub(crate) som: Arc, pub(crate) kind: XfaKind, pub(crate) value: String, pub(crate) numeric: bool, - pub(crate) presence: String, - pub(crate) access: String, + pub(crate) presence: Arc, + pub(crate) access: Arc, pub(crate) repeatable: bool, pub(crate) occur_min: usize, pub(crate) occur_max: Option, pub(crate) index: usize, pub(crate) parent: Option, - pub(crate) children: Vec, + pub(crate) children: Arc<[usize]>, /// 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, + pub(crate) formatted: Option>, +} + +thread_local! { + /// The form the last script on this thread started from, flattened: the next script of the + /// event starts from a copy of it while the engine sends the same form. + static BASE: std::cell::RefCell, Vec)>> = const { std::cell::RefCell::new(None) }; +} + +/// Let go of the form the scripts on this thread started from. +#[cfg(target_arch = "wasm32")] +pub(crate) fn forget_base() { + BASE.with(|b| b.borrow_mut().take()); +} + +/// `root` flattened under a synthetic `form` node (index 0). +fn flattened(root: &XfaNode) -> Vec { + let mut nodes = vec![HNode { + name: "form".into(), + som: "".into(), + kind: XfaKind::Form, + value: String::new(), + numeric: false, + presence: "visible".into(), + access: "open".into(), + repeatable: false, + occur_min: 0, + occur_max: None, + index: 0, + parent: None, + children: Arc::from([]), + gone: false, + formatted: None, + }]; + if let Some(ci) = flatten(root, Some(0), &mut nodes, 1) + && let Some(f) = nodes.get_mut(0) + { + f.children = Arc::from([ci]); + } + nodes } /// The form as a script sees and changes it, with the effects it records. @@ -53,11 +97,25 @@ pub(crate) struct XHost { impl XHost { /// The host for a script on `root` (the template's root subform), with `this` at the node /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. - pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { - let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; - let mut nodes = Vec::new(); - flatten(&form, None, &mut nodes, 0); - let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); + /// With `reuse`, the flattened form is kept on this thread for the next script given the + /// same `root` (the scripts of one event, [`crate::xfa_session`]). + pub(crate) fn new(root: Arc, reuse: bool, target: &str, doc: &XfaDoc) -> XHost { + let nodes = if !reuse { + flattened(&root) + } else { + BASE.with(|b| { + let mut b = b.borrow_mut(); + match b.as_ref() { + Some((r, nodes)) if Arc::ptr_eq(r, &root) => nodes.clone(), + _ => { + let nodes = flattened(&root); + *b = Some((root, nodes.clone())); + nodes + } + } + }) + }; + let current = nodes.iter().position(|n| &*n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); XHost { nodes, effects: Vec::new(), @@ -122,8 +180,8 @@ impl XHost { } if let Some(n) = self.nodes.get_mut(i) { match &e { - XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), - XfaEffect::SetAccess { access, .. } => n.access = access.clone(), + XfaEffect::SetPresence { presence, .. } => n.presence = presence.as_str().into(), + XfaEffect::SetAccess { access, .. } => n.access = access.as_str().into(), _ => {} } } @@ -220,7 +278,7 @@ impl XHost { if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { return false; } - let som = n.som.clone(); + let som = n.som.to_string(); // Cut here, not only in the effect: later reads in the script see what is saved, and // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); @@ -257,21 +315,21 @@ fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usiz } let idx = out.len(); out.push(HNode { - name: n.name.clone(), - som: n.som.clone(), + name: n.name.as_str().into(), + som: n.som.as_str().into(), kind: n.kind.unwrap_or(XfaKind::Subform), value: n.value.clone(), numeric: n.numeric, - presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, - access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, + presence: if n.presence.is_empty() { "visible".into() } else { n.presence.as_str().into() }, + access: if n.access.is_empty() { "open".into() } else { n.access.as_str().into() }, repeatable: n.repeatable, occur_min: n.occur_min, occur_max: n.occur_max, index: n.index, parent, - children: Vec::new(), + children: Arc::from([]), gone: false, - formatted: n.formatted.clone(), + formatted: n.formatted.as_deref().map(Arc::from), }); let mut kids = Vec::new(); for c in &n.children { @@ -280,7 +338,7 @@ fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usiz } } if let Some(h) = out.get_mut(idx) { - h.children = kids; + h.children = kids.into(); } Some(idx) } diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs index c099c5abe8..b7e338057e 100644 --- a/crates/js/src/xfa/instances.rs +++ b/crates/js/src/xfa/instances.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use super::host::{HNode, XHost, host}; use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; use super::nodes::{instances, node_object}; @@ -112,14 +114,14 @@ fn renumber(h: &mut XHost, idx: usize, depth: usize) { } let (parent_som, name, index, kids) = { let Some(n) = h.nodes.get(idx) else { return }; - let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); + let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.to_string()).unwrap_or_default(); (parent_som, n.name.clone(), n.index, n.children.clone()) }; let som = child_som(parent_som, &name, index); if let Some(n) = h.nodes.get_mut(idx) { - n.som = som; + n.som = som.into(); } - for k in kids { + for k in kids.iter().copied() { renumber(h, k, depth + 1); } } @@ -145,7 +147,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) ( HNode { name: n.name.clone(), - som: String::new(), + som: "".into(), kind: n.kind, value: String::new(), numeric: n.numeric, @@ -156,7 +158,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) occur_max: n.occur_max, index: n.index, parent, - children: Vec::new(), + children: Arc::from([]), gone: false, // The copy holds no value, so nothing is shown in its picture either. formatted: None, @@ -167,7 +169,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) let idx = h.nodes.len(); h.nodes.push(template); let mut copied = Vec::new(); - for k in kids { + for k in kids.iter().copied() { // Nested repeating subforms copy their first instance only. let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); if skip { @@ -178,7 +180,7 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) } } if let Some(n) = h.nodes.get_mut(idx) { - n.children = copied; + n.children = copied.into(); } Some(idx) } @@ -206,8 +208,8 @@ impl XHost { return None; } // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. - let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); - let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); + let name = h.nodes.get(first).map(|n| n.name.to_string()).unwrap_or_default(); + let parent_som = h.nodes.get(parent).map(|p| p.som.to_string()).unwrap_or_default(); let som = child_som(parent_som, &name, live.len()); if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { return None; @@ -219,8 +221,10 @@ impl XHost { } // Keep instances together: right after the last one. if let Some(p) = h.nodes.get_mut(parent) { - let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); - p.children.insert(pos, new); + let mut kids = p.children.to_vec(); + let pos = kids.iter().position(|&c| c == last).map_or(kids.len(), |i| i + 1); + kids.insert(pos, new); + p.children = kids.into(); } renumber(h, new, 0); h.added += 1; @@ -260,7 +264,7 @@ impl XHost { if live.len() <= min { return; } - let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + let som = h.nodes.get(gone).map(|n| n.som.to_string()).unwrap_or_default(); if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { return; } diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs index 75943352e6..1f5ae50c17 100644 --- a/crates/js/src/xfa/nodes.rs +++ b/crates/js/src/xfa/nodes.rs @@ -70,14 +70,14 @@ fn value_js(h: &HNode) -> JsValue { /// `(idx, name)` of the child of `parent` named `name`, first instance. pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { let p = h.nodes.get(parent)?; - p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) + p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && &*n.name == name)) } /// Every live instance named `name` under `parent`, in order. pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { h.nodes .get(parent) - .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) + .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && &*n.name == name)).collect()) .unwrap_or_default() } @@ -179,11 +179,11 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize return out; } let Some(n) = h.nodes.get(from) else { return out }; - for &c in &n.children { + for &c in n.children.iter() { if let Some(k) = h.nodes.get(c) && !k.gone { - if k.name == name { + if &*k.name == name { out.push(c); } else { out.extend(descendants_named(h, c, name, depth + 1)); @@ -211,7 +211,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(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), + "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.as_deref().map(str::to_string)).unwrap_or(value)), "name" => s(&name), "somExpression" => s(&som), "className" => s(kind.class_name()), @@ -289,7 +289,7 @@ fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs index f951e02a54..6044c37c05 100644 --- a/crates/js/src/xfa/run.rs +++ b/crates/js/src/xfa/run.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use super::globals::install; use super::host::{Shared, XHost}; use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; @@ -15,7 +17,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim let spawned = std::thread::Builder::new() .name("pdfcraft-xfa-js".into()) .stack_size(crate::SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); + .spawn_scoped(scope, || run_here(script, event, doc, Arc::new(root.clone()), false, limits, &|| {})); match spawned { Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), @@ -23,7 +25,7 @@ pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, lim }) } #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, root.clone(), limits, &|| {}) + run_here(script, event, doc, Arc::new(root.clone()), false, limits, &|| {}) } /// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready @@ -36,7 +38,7 @@ pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNod return XfaOutcome { error: Some(why), ..Default::default() }; } let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) + run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, Arc::new(root), false, limits, started)) } /// Longest the script engine may take to be ready for a script (its thread and context, and @@ -125,7 +127,15 @@ pub(crate) fn warm_up() { } /// `started` is called once the engine is ready: a time limit on the script runs from then. -pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { +pub(crate) fn run_here( + script: &str, + event: &XfaEvent, + doc: &XfaDoc, + root: Arc, + reuse: bool, + limits: Limits, + started: &dyn Fn(), +) -> XfaOutcome { warm_up(); let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { Ok(ctx) => ctx, @@ -133,7 +143,7 @@ pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNo }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - let host_state = XHost::new(root, &event.target, doc); + let host_state = XHost::new(root, reuse, &event.target, doc); let current = host_state.current; ctx.insert_data(RefCell::new(host_state)); let mut out = XfaOutcome::default(); diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index 28ce53994d..797f813332 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -36,8 +36,10 @@ pub struct Script { pub source: String, pub formcalc: bool, pub event: XfaEvent, - /// The form as the caller holds it now. - pub root: XfaNode, + /// The form as the caller holds it now. Send the same `Arc` while the form is unchanged: + /// the next script then starts from the copy the last one was given, without flattening the + /// form again. + pub root: std::sync::Arc, pub limits: Limits, pub timeout: std::time::Duration, } @@ -58,13 +60,13 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome { if s.formcalc { // FormCalc has no engine to start: its time (and its own deadline) begins at once. started(); - return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, s.limits, Some(s.timeout)); + return crate::formcalc::run_formcalc_at(&s.source, &s.event, doc, s.root, true, s.limits, Some(s.timeout)); } if let Some(why) = crate::refuse(&s.source) { started(); return XfaOutcome { error: Some(why), ..Default::default() }; } - crate::xfa::run_here(&s.source, &s.event, doc, s.root, s.limits, started) + crate::xfa::run_here(&s.source, &s.event, doc, s.root, true, s.limits, started) } #[cfg(not(target_arch = "wasm32"))] @@ -236,6 +238,14 @@ mod native { run_script(script, &self.doc, &|| {}) } } + + impl Drop for XfaSession { + /// The scripts ran on this thread: the form they started from is let go of with the + /// event (on other targets it goes with the session's thread). + fn drop(&mut self) { + crate::xfa::forget_base(); + } + } } pub use native::XfaSession; From 71a42a20d77cde8d95fb8592a8c0ccd798e9ca45 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:24:21 +0800 Subject: [PATCH 3/7] M12: merge a run of XFA resets through a set, not a list scan Resets in a row are applied together; the names were deduplicated by scanning the kept list for each new one, quadratic in the names a script passes (its effects may carry hundreds of thousands within the payload cap). The run now keeps a set beside the ordered list, and a reset of the whole form still takes in the rest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK --- crates/engine/src/xfa.rs | 92 ++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index ae56646986..fd1249df2d 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -5,7 +5,7 @@ //! presence and access changes become overrides kept in the PDF, added or removed rows change //! the datasets, and any of those lays the form out again. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode}; @@ -159,20 +159,38 @@ fn to_js(n: &FormNode) -> XfaNode { } } -/// Add the names of a `resetData` call to the run of resets before it. No names means the -/// whole form, which takes in every other. -fn merge_reset(merged: &mut Vec, names: &[String]) { - if merged.is_empty() { - return; - } - if names.is_empty() { - merged.clear(); - return; - } - for n in names { - if !merged.contains(n) { - merged.push(n.clone()); +/// The names of a run of `resetData` calls, applied together: each name once, in the order +/// first given. A call without names resets the whole form, which takes in every other. +#[derive(Default)] +struct ResetRun { + names: Vec, + /// `names` as a set, so adding a name costs the same however many there are (a script + /// may pass hundreds of thousands). + seen: HashSet, + whole: bool, +} + +impl ResetRun { + fn add(&mut self, names: &[String]) { + if self.whole { + return; } + if names.is_empty() { + self.whole = true; + self.names.clear(); + self.seen.clear(); + return; + } + for n in names { + if self.seen.insert(n.clone()) { + self.names.push(n.clone()); + } + } + } + + /// The names to reset; none for the whole form. + fn names(&self) -> &[String] { + if self.whole { &[] } else { &self.names } } } @@ -616,7 +634,7 @@ impl<'a, 'b> Runner<'a, 'b> { } } // Resets in a row, applied together when the run of them ends. - let mut pending_reset: Option> = None; + let mut pending_reset: Option = None; for (at, e) in effects.iter().enumerate() { // Only a click script may print, save, open links or move the focus (not the // calculations a click sets off, nor anything run on open or on a change). @@ -669,14 +687,10 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::ResetData(names) => { // A script resetting several subforms in a row (`resetData` once for each) // resets them together: the same fields end up at their defaults. - if let Some(merged) = pending_reset.as_mut() { - merge_reset(merged, names); - } else { - pending_reset = Some(names.clone()); - } + pending_reset.get_or_insert_with(ResetRun::default).add(names); let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_))); - if !next_is_reset && let Some(names) = pending_reset.take() { - self.reset(&names)?; + if !next_is_reset && let Some(run) = pending_reset.take() { + self.reset(run.names())?; } } XfaEffect::Print => self.run.out.requests.push(Request::Print), @@ -1004,6 +1018,40 @@ pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pd mod tests { use super::*; + /// A run of resets keeps each name once, in the order first given, and a reset of the whole + /// form takes in the rest, before or after it. + #[test] + fn resets_in_a_row_keep_each_name_once_and_a_whole_form_reset_wins() { + let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::>(); + let mut run = ResetRun::default(); + run.add(&v(&["a", "b", "a"])); + run.add(&v(&["c", "b"])); + assert_eq!(run.names(), v(&["a", "b", "c"]).as_slice()); + run.add(&[]); + assert!(run.names().is_empty()); + run.add(&v(&["d"])); + assert!(run.names().is_empty(), "still the whole form"); + let mut first = ResetRun::default(); + first.add(&[]); + first.add(&v(&["a"])); + assert!(first.names().is_empty()); + } + + /// Adding a name to a run costs the same however many it holds: two runs of 60,000 + /// distinct names merge at once (comparing each name with every kept one is 3.6 billion + /// comparisons). + #[test] + fn a_long_run_of_reset_names_merges_in_linear_time() { + let a: Vec = (0..60_000).map(|i| format!("form1[0].a{i}")).collect(); + let b: Vec = (0..60_000).map(|i| format!("form1[0].b{i}")).collect(); + let started = std::time::Instant::now(); + let mut run = ResetRun::default(); + run.add(&a); + run.add(&b); + assert_eq!(run.names().len(), 120_000); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); + } + #[test] fn script_names_match_indexed_som_paths() { assert!(som_matches("form[0].page1[0].qty[0]", "qty")); From e35d028d4a64be0b2d521433494da2133b62ac0d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:46:49 +0800 Subject: [PATCH 4/7] M12: an XFA reset reaches fields a script added while the form opened, before they have widgets resetData resolved its targets through the generated fields, so a field of a row an opening script added, with no widget until the form is laid out again, kept the value a later script gave it, and the final layout saved it. The reset now also walks the live form and puts the template's default back in the data of every matching field with no widget yet (review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ crates/engine/src/xfa.rs | 27 +++++++++++++++++++++++++++ crates/xfa/src/lib.rs | 2 +- crates/xfa/src/script.rs | 11 +++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed53ad2a50..2dd652263d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9507,3 +9507,26 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); black(&s, rewritten); } + +/// A reset in an opening script reaches a field of a row an earlier opening script added, before +/// the form is laid out again with the row's widgets: its data goes back to its default and stays +/// so when the form is saved. +#[test] +fn an_opening_reset_reaches_a_row_added_while_opening() { + let tpl = pdfcraft_xfa::fixtures::scripted_template().replace( + "if (qty.rawValue === null) qty.rawValue = 2;", + r#"if (qty.rawValue === null) qty.rawValue = 2; table._row.addInstance(1);"#, + ) + .replace( + r#""#, + r#""#, + ); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let data = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].item[0]")).unwrap_or(""), "", "reset in the data"); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index fd1249df2d..d0b674bc04 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -590,7 +590,34 @@ impl<'a, 'b> Runner<'a, 'b> { } else { Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect()) }; + // Fields of the live form with no widget yet (a row an opening script added, before the + // form is laid out again): their data goes back to the template's default too, or the + // value would stay and be laid out and saved. + let unshown: Vec = { + let live = self.live(); + let mut out = Vec::new(); + let mut stack: Vec<&pdfcraft_xfa::FormNode> = vec![&live.root]; + while let Some(n) = stack.pop() { + if n.kind == Some(pdfcraft_xfa::NodeKind::Field) + && !by_som.contains_key(&n.som) + && (names.is_empty() || names.iter().any(|name| som_matches(&n.som, name))) + { + out.push(n.som.clone()); + } + stack.extend(n.children.iter()); + } + out + }; + for som in unshown { + let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue }; + let path = pdfcraft_xfa::data_path(self.tpl, &som); + if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text { + self.data_op(pdfcraft_xfa::DataOp::Value { path, text }); + self.changes.values += 1; + } + } if listed.as_ref().is_some_and(Vec::is_empty) { + self.invalidate(); return Ok(()); } pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?; diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 7e63218c90..811e2f4de3 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -103,7 +103,7 @@ pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, exis pub use picture::typed_date; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text, - fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, + fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, reset_default, set_overrides, template_of, }; #[derive(Debug, thiserror::Error)] diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 6946aa9b1c..f0906a603a 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -575,6 +575,17 @@ pub fn fields_by_som(doc: &Document) -> HashMap { out } +/// What a reset gives the field at `som` in the data: its template default, or nothing when it has +/// none. `None` for an object a reset restores no value of: not a field of the template, or a +/// button, signature or picture field. +pub fn reset_default(tpl: &Template, som: &str) -> Option { + let f = crate::data::template_field(tpl, som)?; + if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) { + return None; + } + Some(f.value.plain().unwrap_or_default()) +} + /// Lay the form out again from its template, data and overrides, replacing the pages and the /// generated fields (a script added a row or hid a subform). pub fn rerender(doc: &mut Document, tpl: &Template) -> Result { From ade6b150a3be62223ce9bed21c6aff081a2d18c5 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:58:23 +0800 Subject: [PATCH 5/7] M12: an XFA reset restores groups, saved list values and structured picks, in bounded time The reset of objects an opening script added before they have widgets missed three kinds of value, and its name matching grew with the product of objects and names (review): - Exclusion groups were skipped, so a radio value set and reset in a new row stayed in the data. A group now resets to the on value of its default button, or to nothing. - A choice list reset to the text it shows, not the item value it saves. The default now goes through Field::saved_value, as the live form reads it. - A multi-select list whose picks the data holds as elements compared as empty and could not be written, so its picks survived. A new DataOp::Reset compares and replaces those picks (none for an empty default); DataOp::Value still leaves nodes with children alone. - Reset names are indexed (ResetTargets), so matching an object costs the same however many names a script passes, and forms::reset looks named fields up through sets instead of scanning the list per field. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 56 +++++++++++++++ crates/engine/src/xfa.rs | 116 +++++++++++++++++++++++-------- crates/forms/src/tests.rs | 20 ++++++ crates/forms/src/values.rs | 7 +- crates/xfa/src/data.rs | 34 ++++++++- crates/xfa/src/data/import.rs | 2 +- crates/xfa/src/data/merge.rs | 27 +++++++ crates/xfa/src/data/passwords.rs | 2 +- crates/xfa/src/data/path.rs | 15 ++-- crates/xfa/src/data/values.rs | 5 +- crates/xfa/src/data/write.rs | 7 +- crates/xfa/src/script.rs | 29 ++++++-- crates/xfa/src/tests.rs | 73 +++++++++++++++++++ 13 files changed, 343 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2dd652263d..4734b52de3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9530,3 +9530,59 @@ fn an_opening_reset_reaches_a_row_added_while_opening() { assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added"); assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].item[0]")).unwrap_or(""), "", "reset in the data"); } + +/// A reset in an opening script reaches an exclusion group of a row an earlier opening script +/// added, before the row has widgets: the group's data goes back to the on value of its default +/// button, and stays so when the form is saved. +#[test] +fn an_opening_reset_reaches_an_exclusion_group_of_a_row_added_while_opening() { + let group = r#"AAB"#; + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + "if (qty.rawValue === null) qty.rawValue = 2;", + r#"if (qty.rawValue === null) qty.rawValue = 2; table._row.addInstance(1);"#, + ) + .replace(r#""#, &format!(r#"{group}"#)) + .replace( + r#""#, + r#""#, + ); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let data = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].pick[0]")).unwrap_or(""), "A", "group reset in the data"); +} + +/// A reset in an opening script clears a multi-select list of a hidden subform (no widget) +/// whose picks the data holds as `value` elements: its empty default leaves no pick in the +/// saved data, and the rest of the data stays as it was. +#[test] +fn an_opening_reset_clears_structured_picks_of_a_field_without_a_widget() { + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#""#, + r#"abc"#, + ) + .replace( + "if (qty.rawValue === null) qty.rawValue = 2;", + r#"if (qty.rawValue === null) qty.rawValue = 2; xfa.host.resetData("form.page1.details.tags");"#, + ) + .replace( + "", + r#"
3
ackept
+"#, + ); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("tags.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let data = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(data.value_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].tags[0]")).unwrap_or_default(), "", "picks reset in the data"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].note[0]")), Some("kept")); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].qty[0]")), Some("3")); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index d0b674bc04..3d163ca5f7 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -233,15 +233,52 @@ fn plain_som(som: &str) -> String { out } -/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container -/// such as `table`) name this SOM or one of its ancestors? -fn som_matches(som: &str, name: &str) -> bool { - let plain = plain_som(som); - let n = plain_som(name.trim()); - let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n); - let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect(); - let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect(); - !want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice()) +/// The names a script passed to a reset (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a +/// container such as `table`), indexed, so matching an object against them costs the same +/// however many names there are. +struct ResetTargets { + /// Each name's steps without indexes, joined by `.`. + wants: HashSet, + /// The most steps of a name. + longest: usize, +} + +impl ResetTargets { + fn new(names: &[String]) -> Self { + let mut wants = HashSet::new(); + let mut longest = 0; + for name in names { + let n = plain_som(name.trim()); + let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n); + let steps: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect(); + if !steps.is_empty() { + longest = longest.max(steps.len()); + wants.insert(steps.join(".")); + } + } + ResetTargets { wants, longest } + } + + /// Does a name name this SOM or one of its ancestors: is it a run of the SOM's steps? The + /// runs are looked up, at most [`Self::longest`] steps each, so the work depends on the + /// SOM's depth and not on how many names there are. + fn matches(&self, som: &str) -> bool { + let plain = plain_som(som); + let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect(); + for start in 0..have.len() { + let mut run = String::new(); + for (k, step) in have.iter().skip(start).take(self.longest).enumerate() { + if k > 0 { + run.push('.'); + } + run.push_str(step); + if self.wants.contains(&run) { + return true; + } + } + } + false + } } /// A generated field as the runner tracks it: kind, value and on state. @@ -585,22 +622,20 @@ impl<'a, 'b> Runner<'a, 'b> { fn reset(&mut self, names: &[String]) -> Result<(), String> { self.flush()?; let by_som = self.by_som().clone(); - let listed: Option> = if names.is_empty() { - None - } else { - Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect()) - }; - // Fields of the live form with no widget yet (a row an opening script added, before the - // form is laid out again): their data goes back to the template's default too, or the - // value would stay and be laid out and saved. + let targets = (!names.is_empty()).then(|| ResetTargets::new(names)); + let wanted = |som: &str| targets.as_ref().is_none_or(|t| t.matches(som)); + let listed: Option> = targets.as_ref().map(|_| by_som.iter().filter(|(som, _)| wanted(som)).map(|(_, f)| f.clone()).collect()); + // Fields and exclusion groups of the live form with no widget yet (a row an opening + // script added, before the form is laid out again): their data goes back to the + // template's default too, or the value would stay and be laid out and saved. let unshown: Vec = { let live = self.live(); let mut out = Vec::new(); let mut stack: Vec<&pdfcraft_xfa::FormNode> = vec![&live.root]; while let Some(n) = stack.pop() { - if n.kind == Some(pdfcraft_xfa::NodeKind::Field) + if matches!(n.kind, Some(pdfcraft_xfa::NodeKind::Field | pdfcraft_xfa::NodeKind::ExclGroup)) && !by_som.contains_key(&n.som) - && (names.is_empty() || names.iter().any(|name| som_matches(&n.som, name))) + && wanted(&n.som) { out.push(n.som.clone()); } @@ -611,10 +646,7 @@ impl<'a, 'b> Runner<'a, 'b> { for som in unshown { let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue }; let path = pdfcraft_xfa::data_path(self.tpl, &som); - if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text { - self.data_op(pdfcraft_xfa::DataOp::Value { path, text }); - self.changes.values += 1; - } + self.reset_data(path, text); } if listed.as_ref().is_some_and(Vec::is_empty) { self.invalidate(); @@ -623,6 +655,7 @@ impl<'a, 'b> Runner<'a, 'b> { pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?; self.fields = None; let infos = self.fields().clone(); + let listed: Option> = listed.as_ref().map(|l| l.iter().collect()); for (som, name) in &by_som { if listed.as_ref().is_some_and(|l| !l.contains(name)) { continue; @@ -632,16 +665,21 @@ impl<'a, 'b> Runner<'a, 'b> { continue; } let path = pdfcraft_xfa::data_path(self.tpl, som); - let text = data_text(info); - if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text { - self.data_op(pdfcraft_xfa::DataOp::Value { path, text }); - self.changes.values += 1; - } + self.reset_data(path, data_text(info)); } self.invalidate(); Ok(()) } + /// Put a reset's `text` in the data at `path`, unless it already holds it: a multi-select + /// list's picks (`value` elements) are compared as its lines, and replaced. + fn reset_data(&mut self, path: pdfcraft_xfa::DataPath, text: String) { + if self.data.as_ref().and_then(|d| d.value_at(&path)).unwrap_or_default() != text { + self.data_op(pdfcraft_xfa::DataOp::Reset { path, text }); + self.changes.values += 1; + } + } + /// Apply what a script of `ev` did. Returns whether the form has to be laid out again. fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result { let mut relayout = false; @@ -1079,14 +1117,36 @@ mod tests { assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); } + /// Matching objects against a reset's names costs the same however many names there are: + /// 5,000 objects against 50,000 names that miss them all is 250 million comparisons when + /// each object is compared with every name. + #[test] + fn matching_many_objects_against_many_reset_names_is_bounded() { + let names: Vec = (0..50_000).map(|i| format!("form1.page1.other{i}")).collect(); + let started = std::time::Instant::now(); + let targets = ResetTargets::new(&names); + let hits = (0..5_000).filter(|i| targets.matches(&format!("form1[0].page1[0].table[0].row[{i}].item[0]"))).count(); + assert_eq!(hits, 0); + assert!(targets.matches("form1[0].page1[0].other49999[0]")); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed()); + } + #[test] fn script_names_match_indexed_som_paths() { + let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som); assert!(som_matches("form[0].page1[0].qty[0]", "qty")); assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty")); assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty")); + assert!(som_matches("form[0].page1[0].qty[0]", "$form.form.page1.qty")); assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table")); + assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table.row[0].amount")); assert!(!som_matches("form[0].page1[0].qty[0]", "qt")); assert!(!som_matches("form[0].page1[0].price[0]", "qty")); + assert!(!som_matches("form[0].page1[0].qty[0]", "page1.page1.qty")); + assert!(!som_matches("form[0].page1[0].qty[0]", " . ")); + assert!(!ResetTargets::new(&[]).matches("form[0].qty[0]")); + let many = ResetTargets::new(&["price".into(), "page1.qty".into()]); + assert!(many.matches("form[0].page1[0].qty[0]") && many.matches("form[0].page2[0].price[0]")); assert_eq!( split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)), Some((2, "form[0].table[0]".into(), "row".into(), 2)) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index cca4cb4881..c62a260120 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -5485,3 +5485,23 @@ fn locked_button_export_edits_leave_the_document_unchanged() { assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } } + +/// A reset of named fields costs the same per field however many are named: resetting 60,000 +/// fields by name is not 3.6 billion name comparisons. +#[test] +fn resetting_many_named_fields_is_not_quadratic() { + const N: usize = 60_000; + let kids: String = (0..N).map(|i| format!("{} 0 R ", i + 4)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >>".into(), + "<< /Type /Pages /Kids [] /Count 0 >>".into(), + format!("<< /Fields [{kids}] >>"), + ]; + objs.extend((0..N).map(|i| format!("<< /FT /Tx /T (f{i}) /V (a) /DV (a) >>"))); + let mut doc = document(&objs); + let names: Vec = (0..N).map(|i| format!("f{i}")).collect(); + let started = std::time::Instant::now(); + assert_eq!(reset(&mut doc, Some(&names)).unwrap(), 0); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + assert!(matches!(reset(&mut doc, Some(&["f0".to_string(), "nope".to_string()])), Err(FormError::NoSuchField(n)) if n == "nope")); +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index e8615567b6..24fc61e2ce 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -631,13 +631,16 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result = names.map(|_| all.iter().map(|f| f.name.as_str()).collect()).unwrap_or_default(); if let Some(n) = names - && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) + && let Some(missing) = n.iter().find(|n| !have.contains(n.as_str())) { return Err(FormError::NoSuchField(missing.clone())); } + let named: Option> = names.map(|n| n.iter().map(String::as_str).collect()); let mut changed = 0; - for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { + for f in all.iter().filter(|f| named.as_ref().is_none_or(|n| n.contains(f.name.as_str()))) { if f.value == f.default { continue; } diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 7c387c44ef..6fb29fd95a 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -34,8 +34,8 @@ pub use dates::{iso_to_pattern, pattern_to_iso}; pub(crate) use import::MAX_IMPORT_BYTES; pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use passwords::{clear_password_values, has_password_values}; -pub(crate) use path::template_field; pub use path::{data_path, parse_datasets, som_to_path}; +pub(crate) use path::{template_field, template_object}; pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with}; #[cfg(test)] pub(crate) use write::ENSURED; @@ -83,11 +83,25 @@ impl DataNode { /// elements (one way a multi-select list's picks are stored), those on separate lines. pub fn value_at(&self, path: &[(String, usize)]) -> Option { let n = self.get(path)?; - if n.children.is_empty() || !n.children.iter().all(|c| c.name == "value") { + if !n.holds_picks() { return Some(n.text.clone()); } - Some(n.children.iter().map(|c| c.text.trim()).filter(|t| !t.is_empty()).take(1000).collect::>().join("\n")) + Some(picks_of(n.children.iter().map(|c| c.text.as_str())).collect::>().join("\n")) } + + /// Whether this node holds a multi-select list's picks: children, all `value` elements. + pub(crate) fn holds_picks(&self) -> bool { + !self.children.is_empty() && self.children.iter().all(|c| c.name == "value") + } +} + +/// Most picks a multi-select value is read or written with. +pub(crate) const MAX_PICKS: usize = 1000; + +/// The picks among `texts` (`value` elements, or the lines of a value): non-empty, trimmed, at +/// most [`MAX_PICKS`]. +pub(crate) fn picks_of<'a>(texts: impl Iterator) -> impl Iterator { + texts.map(str::trim).filter(|t| !t.is_empty()).take(MAX_PICKS) } /// One change to the data, for [`write_data_ops`]. @@ -100,6 +114,11 @@ pub enum DataOp { Instances { parent: DataPath, name: String, count: usize }, /// Take the node at `path` out (a script removed a row). Remove(DataPath), + /// A reset to `text`: as [`DataOp::Value`], and also into a node holding a multi-select + /// list's picks (children, all `value` elements), which then holds one `value` per line of + /// `text`, none when it is empty. A node with other children is left as it is, as + /// [`DataOp::Value`] leaves it. + Reset { path: DataPath, text: String }, } impl DataNode { @@ -131,6 +150,15 @@ impl DataNode { n.text = text.clone(); } } + DataOp::Reset { path, text } => { + let Some(n) = self.ensure(path) else { return }; + if n.children.is_empty() { + n.text = text.clone(); + } else if n.holds_picks() { + n.children = + picks_of(text.lines()).map(|t| DataNode { name: "value".into(), text: t.to_string(), children: Vec::new() }).collect(); + } + } DataOp::Instances { parent, name, count } => { if let Some(&last) = count.checked_sub(1).as_ref() { let mut path = parent.clone(); diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs index 006f4ff73b..0f3529bb6d 100644 --- a/crates/xfa/src/data/import.rs +++ b/crates/xfa/src/data/import.rs @@ -166,7 +166,7 @@ pub fn write_data_image( } let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect(); let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); - let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }]; + let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true, picks: false }]; rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse) } diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs index 0aefc634b5..f13f59f93f 100644 --- a/crates/xfa/src/data/merge.rs +++ b/crates/xfa/src/data/merge.rs @@ -311,6 +311,26 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve continue; } let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); + if w.picks && holds_picks(node) { + // A reset of a multi-select list's picks: one `value` per line, none for an + // empty value, unless the node already holds those. + let have: Vec<&str> = super::picks_of(node.children().filter(|c| c.is_element()).map(|c| c.text().unwrap_or(""))).collect(); + let want: Vec<&str> = super::picks_of(w.text.lines()).collect(); + if have == want { + continue; + } + // The first pick's own tag, prefix included. + let tag = node.children().find(|c| c.is_element()).and_then(|c| qname(src, &c.range())).unwrap_or("value"); + let inner: String = want.iter().map(|p| format!("<{tag}>{}", escape(p).0)).collect(); + match replace_edit(src, node, &inner) { + Some(e) => edits.push(e), + None => skipped(warnings, STRUCTURED, &w.som), + } + if dropped { + skipped(warnings, CONTROL, &w.som); + } + continue; + } if node.children().any(|c| c.is_element()) { let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect(); // Ignore existing XML indentation, but not whitespace in the accepted value. @@ -368,6 +388,13 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve } } +/// Whether `node` holds a multi-select list's picks: element children, all `value` elements +/// (as [`DataNode::holds_picks`](super::DataNode::holds_picks) reads the parsed data). +fn holds_picks(node: roxmltree::Node) -> bool { + let mut kids = node.children().filter(|c| c.is_element()).peekable(); + kids.peek().is_some() && kids.all(|c| c.tag_name().name() == "value") +} + /// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged). pub(super) fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option { let mut current: Option = None; diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs index 0f98a3f2b4..8674a4bd89 100644 --- a/crates/xfa/src/data/passwords.rs +++ b/crates/xfa/src/data/passwords.rs @@ -163,7 +163,7 @@ fn password_dataset_wants(doc: &Document) -> Vec { .into_iter() .filter_map(|(som, path, default)| { let cur = data.value_at(&path)?; - (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) + (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false, picks: false }) }) .collect() }) diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs index d3568817d2..caa87cb3f0 100644 --- a/crates/xfa/src/data/path.rs +++ b/crates/xfa/src/data/path.rs @@ -131,6 +131,16 @@ fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Op /// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is /// a button of an exclusion group (whose buttons take their value from the group). pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { + match template_object(tpl, som)? { + Node::Field(f) => Some(f), + _ => None, + } +} + +/// The template object (field, exclusion group, subform...) the object at a SOM path is an +/// instance of, found as [`template_field`] finds a field. `None` for the root and for a button +/// of an exclusion group. +pub(crate) fn template_object<'a>(tpl: &'a Template, som: &str) -> Option<&'a Node> { let path = som_to_path(som); let ((root, _), rest) = path.split_first()?; if tpl.root.common.name.as_deref() != Some(root.as_str()) { @@ -145,10 +155,7 @@ pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Fie _ => return None, }; } - match named_at(children, last, *last_index, 0)? { - Node::Field(f) => Some(f), - _ => None, - } + named_at(children, last, *last_index, 0) } pub(super) fn path_som(path: &DataPath) -> String { diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 8042e774fe..8362960091 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -90,6 +90,9 @@ pub(super) struct Want { pub(super) text: String, /// Create the node even for an empty value (an instance of a repeating subform). pub(super) ensure: bool, + /// A reset ([`DataOp::Reset`](super::DataOp::Reset)): a node holding a multi-select list's picks + /// takes the text as its picks. + pub(super) picks: bool, } /// `tpl`, or the document's own template when none is given (parsed once). @@ -137,7 +140,7 @@ pub(super) fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Templat FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), FieldData::None => continue, }; - out.push(Want { som, path, text, ensure: false }); + out.push(Want { som, path, text, ensure: false, picks: false }); } out } diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs index 4667036fe5..3dffa47b34 100644 --- a/crates/xfa/src/data/write.rs +++ b/crates/xfa/src/data/write.rs @@ -111,9 +111,10 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) }; for op in ops { match op { - DataOp::Value { path, text } if !path.is_empty() => { + DataOp::Value { path, text } | DataOp::Reset { path, text } if !path.is_empty() => { // An empty value clears an existing node and needs no new one. - run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false }); + let picks = matches!(op, DataOp::Reset { .. }); + run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false, picks }); } DataOp::Instances { parent, name, count } => { let done = queued.entry((parent, name.as_str())).or_insert(0); @@ -129,7 +130,7 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) *done += 1; let mut path = parent.clone(); path.push((name.clone(), k)); - run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true }); + run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true, picks: false }); } } DataOp::Remove(path) if !path.is_empty() => { diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index f0906a603a..ab4e7af208 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -575,15 +575,30 @@ pub fn fields_by_som(doc: &Document) -> HashMap { out } -/// What a reset gives the field at `som` in the data: its template default, or nothing when it has -/// none. `None` for an object a reset restores no value of: not a field of the template, or a -/// button, signature or picture field. +/// What a reset gives the field or exclusion group at `som` in the data: its template default +/// as saved (a choice list's item value, not its shown text, as [`Field::saved_value`] gives +/// it), or nothing when it has none. A group's default is the on value of the first of its +/// buttons whose own default is that on value. `None` for an object a reset restores no value +/// of: not a field or group of the template, or a button, signature or picture field. pub fn reset_default(tpl: &Template, som: &str) -> Option { - let f = crate::data::template_field(tpl, som)?; - if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) { - return None; + match crate::data::template_object(tpl, som)? { + Node::Field(f) => { + if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) { + return None; + } + Some(f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default()) + } + Node::ExclGroup(g) => Some( + g.fields + .iter() + .find_map(|f| { + let on = f.items.first().map(|s| s.trim()).filter(|s| !s.is_empty())?; + (f.value.plain()?.trim() == on).then(|| on.to_string()) + }) + .unwrap_or_default(), + ), + _ => None, } - Some(f.value.plain().unwrap_or_default()) } /// Lay the form out again from its template, data and overrides, replacing the pages and the diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..1ef8239823 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4145,3 +4145,76 @@ fn clear_password_values_clears_static_xfa_password_fields() { let packets = read_packets(&doc).unwrap().unwrap(); assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } + +/// A template for the reset defaults: a choice list showing `Label` and saving `code`, with +/// `Label` its default, and an exclusion group whose second button is on by default. +fn reset_defaults_template() -> Template { + let xml = r#""#; + crate::parse::parse(xml).unwrap().0 +} + +/// A reset gives a choice list the default it saves (the item value), not the text it shows, +/// as the live form reads it. +#[test] +fn a_choice_list_resets_to_its_saved_default() { + let tpl = reset_defaults_template(); + assert_eq!(reset_default(&tpl, "form[0].page1[0].kind[0]").as_deref(), Some("code")); +} + +/// An exclusion group resets to the on value of the button that is on by default, or to +/// nothing; its buttons, and push buttons, restore no value of their own. +#[test] +fn an_exclusion_group_resets_to_its_default_button() { + let tpl = reset_defaults_template(); + assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0]").as_deref(), Some("B")); + assert_eq!(reset_default(&tpl, "form[0].page1[0].none[0]").as_deref(), Some("")); + assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0].b[0]"), None); + assert_eq!(reset_default(&tpl, "form[0].page1[0].go[0]"), None); + assert_eq!(reset_default(&tpl, "form[0].page1[0]"), None); + assert_eq!(reset_default(&tpl, "form[0].page1[0].missing[0]"), None); +} + +/// A reset replaces a multi-select list's picks stored as `value` elements, in the packet and +/// in the copy scripts read; an empty default leaves no pick. An ordinary value write still +/// leaves a node with children alone. +#[test] +fn a_reset_replaces_structured_multi_select_picks() { + let xdp = template_with_data(1, "
ab

rich

"); + let path = som_to_path("form[0].tags[0]"); + let value_at = |doc: &Document| data_of(doc).and_then(|d| d.value_at(&path)).unwrap_or_default(); + + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + write_data_ops(&mut doc, &[DataOp::Value { path: path.clone(), text: String::new() }], None).unwrap(); + assert_eq!(value_at(&doc), "a\nb", "an ordinary write leaves the picks"); + let mut copy = data_of(&doc).unwrap(); + copy.apply(&DataOp::Value { path: path.clone(), text: String::new() }); + assert_eq!(copy.value_at(&path).as_deref(), Some("a\nb")); + + let r = write_data_ops(&mut doc, &[DataOp::Reset { path: path.clone(), text: String::new() }], None).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(value_at(&doc), "", "an empty default leaves no pick"); + copy.apply(&DataOp::Reset { path: path.clone(), text: String::new() }); + assert_eq!(copy.value_at(&path).as_deref(), Some("")); + + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + write_data_ops(&mut doc, &[DataOp::Reset { path: path.clone(), text: "c\n\n d&e ".into() }], None).unwrap(); + assert_eq!(value_at(&doc), "c\nd&e"); + let packets = read_packets(&doc).unwrap().unwrap(); + assert!(packets.xdp.contains("cd&e"), "{}", packets.xdp); + let mut copy = data_of(&Document::open(Arc::new(shell(&xdp))).unwrap()).unwrap(); + copy.apply(&DataOp::Reset { path: path.clone(), text: "c\n\n d&e ".into() }); + assert_eq!(copy.value_at(&path).as_deref(), Some("c\nd&e")); + + // Other structured content (rich text) is not picks: a reset leaves it, with a warning. + let body = som_to_path("form[0].body[0]"); + let r = write_data_ops(&mut doc, &[DataOp::Reset { path: body.clone(), text: String::new() }], None).unwrap(); + assert!(!r.warnings.is_empty()); + assert_eq!(data_of(&doc).unwrap().get(&body).map(|n| n.children.len()), Some(1)); + copy.apply(&DataOp::Reset { path: body.clone(), text: String::new() }); + assert_eq!(copy.get(&body).map(|n| n.children.len()), Some(1)); +} From 17ad6701d0c0c3ec2b470acdfc5b4a82975ef42b Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:11:18 +0800 Subject: [PATCH 6/7] M12: wireless89's reset and reopen tests for opening scripts (#4790) Adds three regression tests from wireless89's review of #4791: - xfa_opening_reset_reaches_a_section_shown_by_an_earlier_script (a text field of a section an earlier opening script showed is reset in the widget and the saved data); - xfa_deferred_choice_resets_preserve_the_reset_value_through_reopen (the same for choice lists: multi-select with structured picks and an empty or set default, and a label saved as its export value; widget, open datasets and saved datasets checked separately); - xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once now edits the check box and checks fields, pages and the value through three save/reopen cycles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 115 +++++++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9b94c1a4a0..5e1cb8d77b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6606,11 +6606,22 @@ fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() { let opened = names(&s, id); assert!(opened.iter().all(|n| !n.contains("note")), "every extra section is hidden on opening: {opened:?}"); assert!(opened.iter().any(|n| n.contains("wanted")), "{opened:?}"); - // The saved form opens as it was saved. - let saved = s.save_bytes(id).unwrap(); - let again = s.open("again.pdf", None, saved, None).expect("reopens"); - assert!(s.take_js_output(again).errors.is_empty()); - assert_eq!(names(&s, again), opened); + // User input survives repeated saves and openings, with stable field and page counts + // (strengthened by wireless89 in the review of #4791). + let wanted = opened.iter().find(|n| n.contains("wanted")).unwrap().clone(); + s.apply(id, Edit::SetFieldValue { name: wanted.clone(), value: FieldValue::Check(true) }).unwrap(); + let pages = s.get(id).unwrap().info.pages.len(); + let mut saved = s.save_bytes(id).unwrap(); + for cycle in 0..3 { + let again = s.open("again.pdf", None, saved, None).expect("reopens"); + let out = s.take_js_output(again); + assert!(out.errors.is_empty(), "cycle {cycle}: {out:?}"); + assert_eq!(names(&s, again), opened, "cycle {cycle}: stable fields"); + let doc = s.get(again).unwrap(); + assert_eq!(doc.info.pages.len(), pages, "cycle {cycle}: stable pages"); + assert_eq!(doc.form.iter().find(|f| f.name == wanted).unwrap().value, ["1"], "cycle {cycle}: input survives"); + saved = s.save_bytes(again).unwrap(); + } } /// A script that resets several subforms one after another (`xfa.host.resetData` once for @@ -10288,3 +10299,97 @@ fn an_opening_reset_clears_structured_picks_of_a_field_without_a_widget() { assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].note[0]")), Some("kept")); assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].qty[0]")), Some("3")); } + +/// A later opening script resets a section shown by an earlier one, including its datasets +/// value: the shown field and the saved form hold the template default, not the pre-reset +/// value, through three save/reopen cycles. Test by wireless89 (review of #4791, 2026-10-27). +#[test] +fn xfa_opening_reset_reaches_a_section_shown_by_an_earlier_script() { + let tpl = r#" + + +
previous inputunrelated input
+
"#; + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let mut bytes = Arc::new(pdfcraft_xfa::fixtures::shell(tpl)); + for opening in 0..3 { + let id = s.open("reset.pdf", None, bytes, None).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "opening {opening}: {out:?}"); + let doc = s.get(id).unwrap(); + let note = doc.form.iter().find(|f| f.name == "note").expect("shown section has a widget"); + let shown = note.value.clone(); + assert_eq!(doc.form.iter().find(|f| f.name == "keep").unwrap().value, ["unrelated input"]); + assert_eq!(doc.info.pages.len(), 1); + bytes = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + let data = pdfcraft_xfa::data_of(&cos).unwrap(); + let saved = data.text_at(&pdfcraft_xfa::som_to_path("form[0].extra[0].note[0]")); + assert_eq!((shown, saved), (vec!["default".to_string()], Some("default")), "opening {opening}: displayed and saved values reset"); + } +} + +/// A reset of a choice list in a section an earlier opening script showed (so it has no widget +/// yet when the reset runs) puts the template default back in the shown widget, the open +/// document's datasets and the saved datasets alike, through three openings: a multi-select +/// with structured `` picks and an empty or set default, and a single choice whose +/// default is a display label saved as its export value. Test by wireless89 (review of #4791, +/// 2026-10-30). +#[test] +fn xfa_deferred_choice_resets_preserve_the_reset_value_through_reopen() { + let cases = [ + ("multi_empty", r#""#, "", "AB", ""), + ("multi_default", r#""#, "A", "B", "A"), + ("choice_export", "", "Alpha", "B", "A"), + ]; + let mut failures = Vec::new(); + for (case, ui, default, initial, expected) in cases { + let xml = format!( + r#" +
{initial}KEEP
"# + ); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let mut bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&xml)); + for cycle in 0..3 { + let id = s.open("choices.pdf", None, bytes, None).unwrap(); + assert!(s.take_js_output(id).errors.is_empty(), "{case}, cycle {cycle}: reset script must execute"); + let doc = s.get(id).unwrap(); + let shown = doc.form.iter().find(|f| f.name == "choice").expect("shown choice has a widget").value.clone(); + assert_eq!(doc.form.iter().find(|f| f.name == "keep").unwrap().value, ["KEEP"]); + let opened_data = pdfcraft_xfa::data_of(&doc.editor.as_ref().unwrap().cos).unwrap(); + let path = pdfcraft_xfa::som_to_path("form[0].extra[0].choice[0]"); + let opened = opened_data.value_at(&path).unwrap_or_default(); + bytes = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + let data = pdfcraft_xfa::data_of(&cos).unwrap(); + let stored = data.value_at(&path).unwrap_or_default(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].keep[0]")), Some("KEEP")); + let expected_shown: Vec = if expected.is_empty() { Vec::new() } else { vec![expected.into()] }; + if shown != expected_shown || opened != expected || stored != expected { + failures.push(format!("{case}, cycle {cycle}: shown={shown:?}, opened={opened:?}, stored={stored:?}, expected={expected:?}")); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} From 6c3c2a217648866f9c9e7ff0f5420a9c60263d3f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:38:28 +0800 Subject: [PATCH 7/7] M12: the reset-run test checks that a field no script touches keeps its value Review on #4791: the test's only field outside the reset subforms is written by the script after the resets, so a reset that reached it too would go unseen. A field with typed data that no script touches now keeps it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/engine/src/tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5e1cb8d77b..15e022f6a3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6638,6 +6638,8 @@ fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() { r#"
"# )); body.push_str(r#""#); + // A field no script touches: the resets must leave what was typed in it. + body.push_str(r#"default"#); for n in 0..sections { body.push_str(&format!( r#"default"# @@ -6653,7 +6655,7 @@ fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() { {body} -
typed{notes}
+
typedtyped other{notes}
"# ); @@ -6673,6 +6675,8 @@ fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() { assert!(notes.iter().all(|v| v == &vec!["default".to_string()]), "every section is reset: {notes:?}"); let keep = form.iter().find(|f| f.name.contains("keep")).map(|f| f.value.clone()); assert_eq!(keep, Some(vec!["kept".to_string()]), "the script's later write stands"); + let other = form.iter().find(|f| f.name.contains("other")).map(|f| f.value.clone()); + assert_eq!(other, Some(vec!["typed other".to_string()]), "a field outside the named subforms keeps its value"); } /// The scripts of one event share the form as the engine holds it: a failed script's