diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cd86c83f3e..b1c4b92406 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4355,6 +4355,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; @@ -4436,6 +4438,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")); @@ -4456,6 +4460,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())); @@ -4488,6 +4494,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); @@ -4518,6 +4526,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 @@ -4545,6 +4556,23 @@ 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); +} + +/// 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 0f37933afd..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(), @@ -149,6 +150,7 @@ fn to_js(n: &FormNode) -> XfaNode { occur_max: n.occur_max, index: n.index, children: n.children.iter().map(to_js).collect(), + formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)), } } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 1ab7ca24e5..85d6f90ea0 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1097,10 +1097,16 @@ impl Interp<'_> { self.charge(1) } + /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, + /// relatives and children by name. fn node_member(&mut self, i: usize, name: &str) -> R { let Some(n) = self.node(i) else { return Ok(Val::Null) }; Ok(match name { - "rawValue" | "value" | "formattedValue" => self.node_value(i), + "formattedValue" => match &n.formatted { + Some(f) => Val::Str(f.clone()), + None => self.node_value(i), + }, + "rawValue" | "value" => self.node_value(i), "name" => Val::Str(n.name.clone()), "somExpression" => Val::Str(n.som.clone()), "className" => Val::Str(n.kind.class_name().into()), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index f6bbdb3523..6baa59e5a8 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -380,12 +380,15 @@ mod xfa_model { } } + /// Run the JavaScript `script` for `activity` on the test form, from the node at `target`. fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 }; run_xfa(script, &ev, &doc, &form(), Limits::default()) } + /// A calculate script finds its siblings by name and yields its last expression, and a + /// value it sets comes back as an effect. #[test] fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() { let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate"); @@ -395,6 +398,39 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. + pub(super) fn dated_form() -> XfaNode { + let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; + XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![d], + ..Default::default() + }], + ..Default::default() + } + } + + /// `formattedValue` reads the value as the field's picture shows it, and `rawValue` the + /// value itself; once the script sets the value, `formattedValue` gives that value. + #[test] + fn formatted_value_reads_the_shown_value_until_the_script_sets_one() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |script: &str| run_xfa(script, &ev, &doc, &dated_form(), Limits::default()); + let o = run("this.formattedValue + ' ' + this.rawValue"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20")); + let o = run("this.rawValue = '2026-10-21'; this.formattedValue"); + assert_eq!(o.result.as_deref(), Some("2026-10-21")); + } + + /// Long values a script sets, and long results, are cut where they are stored, with a note. #[test] fn long_values_and_results_are_cut_where_they_are_stored() { // The value kept for later reads is the one the effect saves. @@ -607,6 +643,22 @@ mod formcalc { o.result.unwrap_or_default() } + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script + /// sets the value. + #[test] + fn formcalc_formatted_value_reads_the_shown_value_until_the_script_sets_one() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |script: &str| run_formcalc(script, &ev, &doc, &super::xfa_model::dated_form(), Limits::default()); + let o = run("Concat($.formattedValue, \" \", $.rawValue)"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20")); + let o = run("$.rawValue = \"2026-10-21\"\n$.formattedValue"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("2026-10-21")); + } + + /// FormCalc arithmetic, string conversion and comparisons follow FormCalc's own rules. #[test] fn arithmetic_strings_and_comparisons_follow_formcalc_rules() { assert_eq!(calc("1 + 2 * 3"), "7"); diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..5a693db9f0 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -64,6 +64,9 @@ pub struct XfaNode { /// This instance's index among its same-named siblings. pub index: usize, pub children: Vec, + /// Fields: the value shown in the field's display picture, what `formattedValue` reads; + /// `None` when the picture doesn't change it (the raw value is read then). + pub formatted: Option, } /// The event a script runs for. @@ -205,6 +208,8 @@ pub(crate) struct HNode { pub(crate) children: Vec, /// Removed by `removeInstance`: skipped everywhere. pub(crate) gone: bool, + /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. + pub(crate) formatted: Option, } /// The form as a script sees and changes it, with the effects it records. @@ -346,6 +351,7 @@ impl XHost { // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); n.value = text.clone(); + n.formatted = None; let som = n.som.clone(); if cut { self.clipped_values += 1; @@ -357,10 +363,13 @@ impl XHost { type Shared = RefCell; +/// The form host of the script running in `ctx`. fn host(ctx: &Context) -> JsResult<&Shared> { ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) } +/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] +/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { if depth > MAX_DEPTH || out.len() >= MAX_NODES { return None; @@ -381,6 +390,7 @@ fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usiz parent, children: Vec::new(), gone: false, + formatted: n.formatted.clone(), }); let mut kids = Vec::new(); for c in &n.children { @@ -618,6 +628,8 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) parent, children: Vec::new(), gone: false, + // The copy holds no value, so nothing is shown in its picture either. + formatted: None, }, n.children.clone(), ) @@ -817,6 +829,8 @@ pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { cur } +/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document +/// order, to [`MAX_DEPTH`]. pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { let mut out = Vec::new(); if depth > MAX_DEPTH { @@ -837,6 +851,8 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize out } +/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows +/// it), attributes, relatives, methods, instance managers and children by name. fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let idx = node_index(&arg(args, 0), ctx)?; let key = arg(args, 1); @@ -853,7 +869,7 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsValue::from(idx as f64), "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), - "formattedValue" => s(&value), + "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), "name" => s(&name), "somExpression" => s(&som), "className" => s(kind.class_name()), diff --git a/crates/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/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..5a501fbf20 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -405,6 +405,36 @@ 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 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 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; + } + // 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) +} + +/// 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/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..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")); @@ -475,6 +477,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..64d3948983 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. @@ -186,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; @@ -227,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()); @@ -261,6 +269,7 @@ impl Walker<'_> { occur_max: Some(1), index, children: Vec::new(), + display_picture: f.display_picture.clone(), } } @@ -274,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 { @@ -314,6 +325,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..1babcc62f0 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"); @@ -336,6 +339,41 @@ 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); + // 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 +/// 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); +} + +/// 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(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad00b0115d..e4e0be90c2 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"