From 7176ebde9422c413843aa5209bec8d2f0b62a79e Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:29:08 -0400 Subject: [PATCH 1/4] M6: XFA formattedValue shows a date in the field's display picture Fields keep their . A date display picture turns the ISO value into its pattern for scripts' formattedValue (JavaScript and FormCalc), so date arithmetic on formattedValue works as forms expect. Once a script sets the value, formattedValue gives that value. Number and text display pictures still give the raw value. --- crates/engine/src/tests.rs | 14 +++++++++++ crates/engine/src/xfa.rs | 1 + crates/js/src/formcalc.rs | 6 ++++- crates/js/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++++ crates/js/src/xfa.rs | 11 ++++++++- crates/xfa/src/fixtures.rs | 41 +++++++++++++++++++++++++++++++ crates/xfa/src/layout.rs | 14 +++++++++++ crates/xfa/src/lib.rs | 2 +- crates/xfa/src/model.rs | 3 +++ crates/xfa/src/parse.rs | 1 + crates/xfa/src/script.rs | 4 +++ crates/xfa/src/tests.rs | 26 ++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 13 files changed, 169 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..41d1c8dcf7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4469,6 +4469,20 @@ fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() { assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]); } +/// A row of dates worked out the way leave forms do it: the calculation checks that the raw value +/// is a whole ISO date, then adds days to the date as `formattedValue` shows it (`YYYYMMDD`). +#[test] +fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_row_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("row.pdf", None, bytes, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); + s.apply(id, Edit::SetFieldValue { name: "from".into(), value: FieldValue::Text("20261020".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "days".into(), value: FieldValue::Text("3".into()) }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors); +} + #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933afd..43e00a5780 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -149,6 +149,7 @@ fn to_js(n: &FormNode) -> XfaNode { occur_max: n.occur_max, index: n.index, children: n.children.iter().map(to_js).collect(), + formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)), } } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index dc32d457bf..729cb1a889 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1100,7 +1100,11 @@ impl Interp<'_> { fn node_member(&mut self, i: usize, name: &str) -> R { let Some(n) = self.node(i) else { return Ok(Val::Null) }; Ok(match name { - "rawValue" | "value" | "formattedValue" => self.node_value(i), + "formattedValue" => match &n.formatted { + Some(f) => Val::Str(f.clone()), + None => self.node_value(i), + }, + "rawValue" | "value" => self.node_value(i), "name" => Val::Str(n.name.clone()), "somExpression" => Val::Str(n.som.clone()), "className" => Val::Str(n.kind.class_name().into()), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..b04f3c06c6 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -395,6 +395,38 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. + pub(super) fn dated_form() -> XfaNode { + let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; + XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![d], + ..Default::default() + }], + ..Default::default() + } + } + + /// `formattedValue` reads the value as the field's picture shows it, and `rawValue` the + /// value itself; once the script sets the value, `formattedValue` gives that value. + #[test] + fn formatted_value_reads_the_shown_value_until_the_script_sets_one() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |script: &str| run_xfa(script, &ev, &doc, &dated_form(), Limits::default()); + let o = run("this.formattedValue + ' ' + this.rawValue"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20")); + let o = run("this.rawValue = '2026-10-21'; this.formattedValue"); + assert_eq!(o.result.as_deref(), Some("2026-10-21")); + } + #[test] fn long_values_and_results_are_cut_where_they_are_stored() { // The value kept for later reads is the one the effect saves. @@ -607,6 +639,21 @@ mod formcalc { o.result.unwrap_or_default() } + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script + /// sets the value. + #[test] + fn formcalc_formatted_value_reads_the_shown_value_until_the_script_sets_one() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |script: &str| run_formcalc(script, &ev, &doc, &super::xfa_model::dated_form(), Limits::default()); + let o = run("Concat($.formattedValue, \" \", $.rawValue)"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20")); + let o = run("$.rawValue = \"2026-10-21\"\n$.formattedValue"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("2026-10-21")); + } + #[test] fn arithmetic_strings_and_comparisons_follow_formcalc_rules() { assert_eq!(calc("1 + 2 * 3"), "7"); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..a1be639cad 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -64,6 +64,9 @@ pub struct XfaNode { /// This instance's index among its same-named siblings. pub index: usize, pub children: Vec, + /// Fields: the value shown in the field's display picture, what `formattedValue` reads; + /// `None` when the picture doesn't change it (the raw value is read then). + pub formatted: Option, } /// The event a script runs for. @@ -205,6 +208,8 @@ pub(crate) struct HNode { pub(crate) children: Vec, /// Removed by `removeInstance`: skipped everywhere. pub(crate) gone: bool, + /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. + pub(crate) formatted: Option, } /// The form as a script sees and changes it, with the effects it records. @@ -346,6 +351,7 @@ impl XHost { // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); n.value = text.clone(); + n.formatted = None; let som = n.som.clone(); if cut { self.clipped_values += 1; @@ -381,6 +387,7 @@ fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usiz parent, children: Vec::new(), gone: false, + formatted: n.formatted.clone(), }); let mut kids = Vec::new(); for c in &n.children { @@ -618,6 +625,8 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) parent, children: Vec::new(), gone: false, + // The copy holds no value, so nothing is shown in its picture either. + formatted: None, }, n.children.clone(), ) @@ -853,7 +862,7 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsValue::from(idx as f64), "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), - "formattedValue" => s(&value), + "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), "name" => s(&name), "somExpression" => s(&som), "className" => s(kind.class_name()), diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 54bb9bc216..4c8f29df53 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -352,6 +352,47 @@ pub fn many_calculations_template(sections: usize, rows: usize) -> String { ) } +/// Shaped like a leave form's row of dates: `from`, a date field typed and shown as `YYYYMMDD` +/// (its data keeps ISO dates), `days`, an invisible `minus1` one less than `days`, and `to`, +/// which a calculation works out as `from` plus `minus1` days. As such forms do, it checks that +/// `from`'s raw value is a whole ISO date, then does its date arithmetic on `formattedValue`. +pub fn date_row_template() -> String { + r##" + + + +"## + .to_string() +} + /// A PDF shell around `xdp`: one placeholder page, `/NeedsRendering true`, no fields. pub fn shell(xdp: &str) -> Vec { let objs: Vec> = vec![ diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 62d7beedf1..75ffae923c 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -405,6 +405,20 @@ fn date_pattern(picture: Option<&str>) -> String { inner.replace('E', "d").to_ascii_lowercase() } +/// What scripts read as a field's `formattedValue` when its display picture is `picture`: the +/// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into +/// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a +/// clause this can't read and for a value that isn't an ISO date: the raw value stands then. +pub fn formatted_value(picture: &str, raw: &str) -> Option { + let first = picture.split('|').next()?.trim(); + let rest = first.strip_prefix("date")?; + if !rest.starts_with(['{', '(']) { + return None; + } + let shown = iso_to_pattern(raw, &date_pattern(Some(first))); + (shown != raw).then_some(shown) +} + fn button_action(scripts: &[Script]) -> Option { let s = scripts.iter().find(|s| s.activity == "click")?; let t = &s.text; diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index bf4414970e..4b42d39619 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -95,7 +95,7 @@ pub use data::{ pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, }; -pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, layout}; +pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; pub use parse::{measure, parse}; pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 572675f958..327f7fc417 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -503,6 +503,9 @@ pub struct Field { pub validate_message: Option, /// Numeric fields (numericEdit, or a decimal/integer/float value) give scripts numbers. pub numeric: bool, + /// The display picture clause (``), e.g. `date{YYYYMMDD}`: how the value + /// shows when not being edited, and what scripts read as `formattedValue`. + pub display_picture: Option, } impl Field { diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 4582d59b22..c753e5aedb 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -475,6 +475,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { validate: validate_el.and_then(|v| child(v, "script")).map(|s| parse_script(s, "validate")), validate_message, numeric, + display_picture: child(n, "format").and_then(|f| child(f, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()), } } diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 17a0376641..f75235e200 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -44,6 +44,8 @@ pub struct FormNode { pub occur_max: Option, pub index: usize, pub children: Vec, + /// Fields: the display picture (see [`Field::display_picture`]), for `formattedValue`. + pub display_picture: Option, } /// A script the template runs for an event of one node instance. @@ -261,6 +263,7 @@ impl Walker<'_> { occur_max: Some(1), index, children: Vec::new(), + display_picture: f.display_picture.clone(), } } @@ -314,6 +317,7 @@ impl Walker<'_> { occur_max: sf.occur.max, index, children, + display_picture: None, }); } } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 03eb2c67ad..963056f10b 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -336,6 +336,32 @@ fn dates_convert_between_iso_and_acrobat_patterns() { assert_eq!(pattern_to_iso("hello", "mm/dd/yyyy"), None); } +/// What scripts read as `formattedValue`: a date display picture shows the ISO value in its +/// pattern (the first of several alternatives); other kinds of picture, and values that aren't +/// ISO dates, leave the raw value. +#[test] +fn formatted_values_follow_date_display_pictures() { + assert_eq!(crate::formatted_value("date{YYYYMMDD}", "2026-10-20").as_deref(), Some("20261020")); + assert_eq!(crate::formatted_value("date{MM/DD/YYYY}", "2026-10-20").as_deref(), Some("10/20/2026")); + assert_eq!(crate::formatted_value("date{YYYYMMDD}|date{DDMMMYY}", "2026-10-20").as_deref(), Some("20261020")); + assert_eq!(crate::formatted_value("date{YYYY-MM-DD}", "2026-10-20"), None, "shown as it is"); + assert_eq!(crate::formatted_value("num{zzz9}", "2026-10-20"), None); + assert_eq!(crate::formatted_value("date{YYYYMMDD}", "20261020"), None, "not an ISO date"); + assert_eq!(crate::formatted_value("date{YYYYMMDD}", ""), None); +} + +/// A field's `` is its display picture, kept apart from its `` edit +/// picture; a field without one has none. +#[test] +fn display_pictures_are_parsed_apart_from_edit_pictures() { + let (tpl, _) = parse(&crate::fixtures::date_row_template()).unwrap(); + let Node::Subform(page) = &tpl.root.children[0] else { panic!() }; + let Node::Field(from) = &page.children[0] else { panic!() }; + assert_eq!((from.picture.as_deref(), from.display_picture.as_deref()), (Some("date{YYYYMMDD}"), Some("date{YYYYMMDD}"))); + let Node::Field(days) = &page.children[1] else { panic!() }; + assert_eq!(days.display_picture, None); +} + #[test] fn data_fills_the_fields_and_adds_rows() { let form = layout_xml(&template_with_data(1, DATA)).unwrap(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..0c99de7b67 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (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; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, 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. 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; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" From 6a8ea287aa1a0e8ca4509c9e6dc7e27f5a4d3deb Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:36:29 -0400 Subject: [PATCH 2/4] M6: doc comments on the functions the formattedValue change touches --- crates/engine/src/tests.rs | 14 ++++++++++++++ crates/engine/src/xfa.rs | 1 + crates/js/src/formcalc.rs | 2 ++ crates/js/src/tests.rs | 5 +++++ crates/js/src/xfa.rs | 7 +++++++ crates/xfa/src/layout.rs | 2 ++ crates/xfa/src/parse.rs | 2 ++ crates/xfa/src/script.rs | 8 ++++++++ crates/xfa/src/tests.rs | 4 ++++ 9 files changed, 45 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 41d1c8dcf7..f1260084fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4279,6 +4279,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]); } +/// A form's data exports, imports and takes pictures through the session, and what it holds +/// comes back the same. #[test] fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { use pdfcraft_xfa::som_to_path; @@ -4360,6 +4362,8 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { assert_eq!(d2.page_images(0).len(), 2); } +/// Importing data into a static form replaces every value, emptying the fields the file +/// doesn't name, and pictures chosen for it reach its data. #[test] fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_data() { let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1")); @@ -4380,6 +4384,8 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_ assert!(xml.contains("Bob") && !xml.contains("1"), "{xml}"); } +/// Importing data into a dynamic form adds and removes the rows of its repeating subform to +/// match, and the calculations over them run again. #[test] fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -4412,6 +4418,8 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { assert!(String::from_utf8_lossy(&out).contains("only")); } +/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine within +/// the opening's budget, and a change recalculates them within the event's. #[test] fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { let (sections, rows) = (10, 20); @@ -4442,6 +4450,9 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { eprintln!("recalculated after a change in {:?}", started.elapsed()); } +/// The scripts of one event share the form as the engine holds it: a failed script's +/// half-done writes reach nobody, and a script reads the saved value a choice list was given +/// by its shown text. #[test] fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() { // In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate @@ -4483,6 +4494,9 @@ fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() { assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors); } +/// A scripted form end to end: initialize and calculate on open, recalculation after a change, +/// a failing validation's message, buttons that add and remove rows and show messages, a check +/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all. #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 43e00a5780..34c2de15e5 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -129,6 +129,7 @@ pub(crate) struct XfaRun<'a> { pub ran_away: bool, } +/// The live form node `n`, with its descendants, as the script engine's snapshot of it. fn to_js(n: &FormNode) -> XfaNode { XfaNode { name: n.name.clone(), diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 729cb1a889..74f8fef467 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1097,6 +1097,8 @@ impl Interp<'_> { self.charge(1) } + /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, + /// relatives and children by name. fn node_member(&mut self, i: usize, name: &str) -> R { let Some(n) = self.node(i) else { return Ok(Val::Null) }; Ok(match name { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index b04f3c06c6..e2c51c4379 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -380,12 +380,15 @@ mod xfa_model { } } + /// Run the JavaScript `script` for `activity` on the test form, from the node at `target`. fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 }; run_xfa(script, &ev, &doc, &form(), Limits::default()) } + /// A calculate script finds its siblings by name and yields its last expression, and a + /// value it sets comes back as an effect. #[test] fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() { let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate"); @@ -427,6 +430,7 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("2026-10-21")); } + /// Long values a script sets, and long results, are cut where they are stored, with a note. #[test] fn long_values_and_results_are_cut_where_they_are_stored() { // The value kept for later reads is the one the effect saves. @@ -654,6 +658,7 @@ mod formcalc { assert_eq!(o.result.as_deref(), Some("2026-10-21")); } + /// FormCalc arithmetic, string conversion and comparisons follow FormCalc's own rules. #[test] fn arithmetic_strings_and_comparisons_follow_formcalc_rules() { assert_eq!(calc("1 + 2 * 3"), "7"); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a1be639cad..5a693db9f0 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -363,10 +363,13 @@ impl XHost { type Shared = RefCell; +/// The form host of the script running in `ctx`. fn host(ctx: &Context) -> JsResult<&Shared> { ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) } +/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] +/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { if depth > MAX_DEPTH || out.len() >= MAX_NODES { return None; @@ -826,6 +829,8 @@ pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { cur } +/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document +/// order, to [`MAX_DEPTH`]. pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { let mut out = Vec::new(); if depth > MAX_DEPTH { @@ -846,6 +851,8 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize out } +/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows +/// it), attributes, relatives, methods, instance managers and children by name. fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let idx = node_index(&arg(args, 0), ctx)?; let key = arg(args, 1); diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 75ffae923c..6a38a9743a 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -419,6 +419,8 @@ pub fn formatted_value(picture: &str, raw: &str) -> Option { (shown != raw).then_some(shown) } +/// The native PDF action a button's click script amounts to (a reset, print, save or link), +/// else the script itself to run; `None` without a click script. fn button_action(scripts: &[Script]) -> Option { let s = scripts.iter().find(|s| s.activity == "click")?; let t = &s.text; diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index c753e5aedb..92e9e5c28a 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -407,6 +407,8 @@ fn parse_items(n: XmlNode, warnings: &mut Vec) -> (Vec, Vec` element as the template model: its widget, caption, value, items, pictures, +/// access and scripts. fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { let ui = child(n, "ui"); let ui_el = ui.and_then(|u| u.children().find(|c| c.is_element() && c.tag_name().name() != "extras" && c.tag_name().name() != "picture")); diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index f75235e200..64d3948983 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -188,6 +188,8 @@ impl Walker<'_> { true } + /// Note the scripted events of the object at `som`: its event scripts, its calculation and + /// its validation (with the message a failing one shows). fn push_events(&mut self, som: &str, scripts: &[Script], calculate: Option<&Script>, validate: Option<&Script>, message: Option<&str>) { if self.events.len() >= MAX_EVENTS { self.truncated = true; @@ -229,14 +231,18 @@ impl Walker<'_> { } } + /// The presence of the object at `som`: a script's override, else the template's `p`. fn presence(&self, som: &str, p: Presence) -> String { self.ov.presence.get(som).cloned().unwrap_or_else(|| presence_name(p).to_string()) } + /// The access of the object at `som`: a script's override, else the template's `a`. fn access(&self, som: &str, a: Access) -> String { self.ov.access.get(som).cloned().unwrap_or_else(|| access_name(a).to_string()) } + /// The live node of field `f`, instance `index` under `parent_som`, valued from the data or + /// its template default (`radio`: the value its exclusion group chose), with its events noted. fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode { let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index); let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); @@ -277,6 +283,8 @@ impl Walker<'_> { } } + /// The live nodes of template `nodes` under `parent_som`: as many instances of a subform as + /// the data or its occurrence asks for, areas, fields, exclusion groups and draws. fn children(&mut self, nodes: &[Node], parent_som: &str, depth: usize) -> Vec { let mut out = Vec::new(); if depth > MAX_DEPTH { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 963056f10b..22ddb5ed29 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -310,6 +310,7 @@ fn jpeg_headers_give_the_size() { const DATA: &str = "
Singh12001-02-03N2019StudiedDelhi2021WorkedToronto2023MovedOttawa
"; +/// The datasets packet parses into a tree whose nodes the fields' data paths find. #[test] fn datasets_parse_into_a_tree_and_som_paths_find_their_nodes() { let d = parse_datasets(&template_with_data(1, DATA)).expect("datasets"); @@ -323,6 +324,8 @@ fn datasets_parse_into_a_tree_and_som_paths_find_their_nodes() { assert!(parse_datasets("").is_none()); } +/// ISO dates convert to and from the date patterns fields show; text that isn't a date is left +/// alone or refused. #[test] fn dates_convert_between_iso_and_acrobat_patterns() { assert_eq!(iso_to_pattern("2001-02-03", "yyyy-mm-dd"), "2001-02-03"); @@ -362,6 +365,7 @@ fn display_pictures_are_parsed_apart_from_edit_pictures() { assert_eq!(days.display_picture, None); } +/// The datasets fill the fields they bind to and add the rows the data holds. #[test] fn data_fills_the_fields_and_adds_rows() { let form = layout_xml(&template_with_data(1, DATA)).unwrap(); From 21b6267129accfd3ae163eca50dc214f4b17047b Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:57:14 -0400 Subject: [PATCH 3/4] M6: a display picture with month or weekday names leaves formattedValue raw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit date{DD MMM YYYY} and date{EEE …} have parts formatted_value can't show yet, so it gives the raw value instead of text like 20 mmm 2026. --- crates/xfa/src/data.rs | 2 +- crates/xfa/src/layout.rs | 12 ++++++++++-- crates/xfa/src/tests.rs | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index d3e1f2c1ae..fc0997facb 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -239,7 +239,7 @@ fn date_pattern_of(doc: &Document, f: &FieldDatum) -> Option { } /// The tokens of a date pattern: `yyyy`, `yy`, `mm`, `m`, `dd`, `d`, or a literal character. -fn date_tokens(pattern: &str) -> Vec { +pub(crate) fn date_tokens(pattern: &str) -> Vec { let mut out = Vec::new(); let chars: Vec = pattern.chars().collect(); let mut i = 0; diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 6a38a9743a..c71f63b402 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -408,14 +408,22 @@ fn date_pattern(picture: Option<&str>) -> String { /// What scripts read as a field's `formattedValue` when its display picture is `picture`: the /// ISO date `raw` shown in a date picture (`date{YYYYMMDD}` turns `2026-10-20` into /// `20261020`; of several alternatives, the first). `None` for other kinds of picture, for a -/// clause this can't read and for a value that isn't an ISO date: the raw value stands then. +/// clause this can't read or show (month and weekday names) and for a value that isn't an ISO +/// date: the raw value stands then. pub fn formatted_value(picture: &str, raw: &str) -> Option { let first = picture.split('|').next()?.trim(); let rest = first.strip_prefix("date")?; if !rest.starts_with(['{', '(']) { return None; } - let shown = iso_to_pattern(raw, &date_pattern(Some(first))); + let pattern = date_pattern(Some(first)); + // A picture with parts this can't show yet (month or weekday names, `MMM` or `EEE`) would + // leave them as letters: the raw value stands then. + let shows = |t: &String| !t.chars().all(|c| c.is_ascii_alphabetic()) || matches!(t.as_str(), "yyyy" | "yy" | "mm" | "m" | "dd" | "d"); + if !crate::data::date_tokens(&pattern).iter().all(shows) { + return None; + } + let shown = iso_to_pattern(raw, &pattern); (shown != raw).then_some(shown) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 22ddb5ed29..0a655dd153 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -351,6 +351,9 @@ fn formatted_values_follow_date_display_pictures() { assert_eq!(crate::formatted_value("num{zzz9}", "2026-10-20"), None); assert_eq!(crate::formatted_value("date{YYYYMMDD}", "20261020"), None, "not an ISO date"); assert_eq!(crate::formatted_value("date{YYYYMMDD}", ""), None); + // Month and weekday names aren't shown yet: the raw value stands rather than `20 mmm 2026`. + assert_eq!(crate::formatted_value("date{DD MMM YYYY}", "2026-10-20"), None); + assert_eq!(crate::formatted_value("date{EEE MM/DD/YYYY}", "2026-10-20"), None); } /// A field's `` is its display picture, kept apart from its `` edit From 526d87d85c947c1745a351aad75f5173d33db673 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:09:00 -0400 Subject: [PATCH 4/4] M6: formattedValue formats only a value of the ISO date's own shape 2026-1-2 isn't an ISO date and stands as it is. A test also pins that parentheses hold a picture's locale (date(en_US){YYYYMMDD} formats) and that a clause without braces has no pattern. --- crates/xfa/src/layout.rs | 6 ++++++ crates/xfa/src/tests.rs | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index c71f63b402..5a501fbf20 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -423,6 +423,12 @@ pub fn formatted_value(picture: &str, raw: &str) -> Option { if !crate::data::date_tokens(&pattern).iter().all(shows) { return None; } + // Only the ISO date's own shape (`2026-10-20`): `2026-1-2` is not one, and stands. + let b = raw.as_bytes(); + let iso = b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() }); + if !iso { + return None; + } let shown = iso_to_pattern(raw, &pattern); (shown != raw).then_some(shown) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 0a655dd153..1babcc62f0 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -354,6 +354,11 @@ fn formatted_values_follow_date_display_pictures() { // Month and weekday names aren't shown yet: the raw value stands rather than `20 mmm 2026`. assert_eq!(crate::formatted_value("date{DD MMM YYYY}", "2026-10-20"), None); assert_eq!(crate::formatted_value("date{EEE MM/DD/YYYY}", "2026-10-20"), None); + // Only the ISO date's own shape is formatted. + assert_eq!(crate::formatted_value("date{YYYYMMDD}", "2026-1-2"), None); + // Parentheses hold the picture's locale and braces its pattern: one without braces has none. + assert_eq!(crate::formatted_value("date(en_US){YYYYMMDD}", "2026-10-20").as_deref(), Some("20261020")); + assert_eq!(crate::formatted_value("date(YYYYMMDD)", "2026-10-20"), None); } /// A field's `` is its display picture, kept apart from its `` edit