diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c25427fc1..9365e744c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4206,6 +4206,31 @@ fn xfa_dates_are_written_and_read_back_in_their_data_picture() { assert_eq!(value("when"), vec!["10/18/2026".to_string()]); } +#[test] +fn each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::sibling_date_template(""))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + // Two siblings named `due` (laid out as `due` and `due_2`) and a field bound `YYYYMM`. + for name in ["due", "due_2", "month"] { + s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + } + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + let at = |som: &str| data.text_at(&pdfcraft_xfa::som_to_path(som)).map(str::to_string); + assert_eq!(at("form[0].page[0].due[0]").as_deref(), Some("20261018"), "the first sibling's data picture"); + assert_eq!(at("form[0].page[0].due[1]").as_deref(), Some("18102026"), "the second sibling's own"); + // A data picture without a day would drop the one the field shows: the data keeps ISO. + assert_eq!(at("form[0].page[0].month[0]").as_deref(), Some("2026-10-18")); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + for name in ["due", "due_2", "month"] { + let f = d2.form.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")); + assert_eq!(f.value, vec!["10/18/2026".to_string()], "{name} reads back"); + } +} + #[test] fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { // Regression: editing a field replaced the whole data element with the fields' values. diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 30084d960..efd2d1848 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::XfaError; -use crate::model::{Field, Node, Template}; +use crate::model::{Field, Node, SomIndices, Template}; use crate::packets::{Encoding, encode}; const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; @@ -175,6 +175,16 @@ pub(crate) fn date_letters(picture: &str) -> Option { Some(inner.replace('E', "d").to_ascii_lowercase()) } +/// The date letters of `f`'s data picture, when it holds a whole date. A data picture without a +/// year, a month or a day can't hold the date the field shows, so such a field's data keeps +/// ISO dates. +pub(crate) fn data_date_letters(f: &Field) -> Option { + let letters = f.data_picture.as_deref().and_then(date_letters)?; + let tokens = date_tokens(&letters); + let has = |any: &[&str]| tokens.iter().any(|t| any.contains(&t.as_str())); + (has(&["yyyy", "yy"]) && has(&["mm", "m"]) && has(&["dd", "d"])).then_some(letters) +} + /// A date as the data holds it, written in `data_pattern` (the field's data picture), read as /// an ISO date. A value that doesn't match, or a field without a data picture, is taken as it /// is: the data holds ISO dates by default. @@ -190,24 +200,56 @@ fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { } } -/// The template field a SOM path names (instance numbers aside), looking through unnamed -/// subforms and areas as [`data_path`] does; `None` when the template has no such field. -fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { +/// The child of `nodes` the SOM segment `name[index]` names, numbered as the form numbers its +/// objects ([`SomIndices`]): same-named siblings count up, and the instances of a repeating +/// subform take the numbers from its own on. Unnamed subforms and areas are looked through, +/// as [`named`] does. +fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Option<&'a Node> { + if depth > MAX_DEPTH { + return None; + } + let sibs = SomIndices::of(nodes); + let mut repeating = None; + for (i, n) in nodes.iter().enumerate() { + if n.common().name.as_deref() != Some(name) { + continue; + } + let sib = sibs.at(i); + if sib == index { + return Some(n); + } + if sib < index && matches!(n, Node::Subform(sf) if sf.occur.max != Some(1)) { + repeating = Some(n); + } + } + repeating.or_else(|| { + nodes.iter().find_map(|n| match n { + Node::Subform(sf) if sf.common.name.is_none() => named_at(&sf.children, name, index, depth + 1), + Node::Area(a) if a.common.name.is_none() => named_at(&a.children, name, index, depth + 1), + _ => None, + }) + }) +} + +/// The template field the object at a SOM path is an instance of, each segment's number counted +/// 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> { let path = som_to_path(som); let ((root, _), rest) = path.split_first()?; if tpl.root.common.name.as_deref() != Some(root.as_str()) { return None; } - let ((last, _), inner) = rest.split_last()?; + let ((last, last_index), inner) = rest.split_last()?; let mut children: &[Node] = &tpl.root.children; - for (name, _) in inner { - children = match named(children, name, 0)? { + for (name, index) in inner { + children = match named_at(children, name, *index, 0)? { Node::Subform(sf) => &sf.children, Node::Area(a) => &a.children, _ => return None, }; } - match named(children, last, 0)? { + match named_at(children, last, *last_index, 0)? { Node::Field(f) => Some(f), _ => None, } @@ -215,7 +257,7 @@ fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { /// The date letters of the data picture of the field `som` names, when it has one. fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option { - tpl.and_then(|t| template_field(t, som)).and_then(|f| f.data_picture.as_deref()).and_then(date_letters) + tpl.and_then(|t| template_field(t, som)).and_then(data_date_letters) } /// What a field holds, for writing the datasets. diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index e368a4fa4..33dbb017e 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -283,6 +283,42 @@ pub fn bound_date_template(data: &str) -> String { ) } +/// Date fields shown as `MM/DD/YYYY` whose data pictures differ: two siblings both named +/// `due`, the first bound `YYYYMMDD` and the second `DDMMYYYY`, and `month`, bound `YYYYMM` +/// (a year and month, no day). +pub fn sibling_date_template(data: &str) -> String { + let datasets = if data.is_empty() { + String::new() + } else { + format!("{data}") + }; + let field = |name: &str, bind: &str| { + format!( + r#"date{{MM/DD/YYYY}}date{{{bind}}}"# + ) + }; + format!( + r##" + + +{datasets} + +"##, + field("due", "YYYYMMDD"), + field("due", "DDMMYYYY"), + field("month", "YYYYMM") + ) +} + /// A 2 × 2 RGBA PNG: red, green / blue, transparent. pub fn tiny_png() -> Vec { TINY_PNG.to_vec() diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 1b24e8c8d..b7c2605a6 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use base64::Engine; use crate::XfaError; -use crate::data::{DataNode, data_path, data_to_iso, date_letters, is_on, iso_to_pattern}; +use crate::data::{DataNode, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern}; use crate::model::*; use crate::text::{self, Block, Face, Span}; @@ -1638,7 +1638,7 @@ impl Layouter<'_> { let pattern = date_pattern(f.picture.as_deref()); // The data holds the date in the field's data picture, or else as an ISO date; the // field shows it in its own pattern. - let data_pattern = f.data_picture.as_deref().and_then(date_letters); + let data_pattern = data_date_letters(f); let shown = plain_value .as_ref() .map(|v| if data_value.is_some() { iso_to_pattern(&data_to_iso(v, data_pattern.as_deref()), &pattern) } else { v.clone() }); diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 17a037664..9d158ac6c 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -589,46 +589,6 @@ pub fn template_of(doc: &Document) -> Result { crate::parse(&packets.xdp).map(|(t, _)| t) } -/// The template field the object at `som` is an instance of, when it is a field outside an -/// exclusion group (whose buttons take their value from the group). -fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> { - let path = crate::data::som_to_path(som); - let (first, rest) = path.split_first()?; - if tpl.root.common.name.as_deref() != Some(first.0.as_str()) { - return None; - } - let mut level: &[Node] = &tpl.root.children; - let mut found: Option<&Node> = None; - for (name, _) in rest { - let next = named_in(level, name, 0)?; - level = match next { - Node::Subform(sf) => &sf.children, - Node::Area(a) => &a.children, - _ => &[], - }; - found = Some(next); - } - match found { - Some(Node::Field(f)) => Some(f), - _ => None, - } -} - -/// The child of `nodes` named `name`, looking through unnamed subforms and areas (as data -/// paths do); exclusion groups are not looked into. -fn named_in<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { - if depth > MAX_DEPTH { - return None; - } - nodes.iter().find(|n| n.common().name.as_deref() == Some(name)).or_else(|| { - nodes.iter().find_map(|n| match n { - Node::Subform(sf) if sf.common.name.is_none() => named_in(&sf.children, name, depth + 1), - Node::Area(a) if a.common.name.is_none() => named_in(&a.children, name, depth + 1), - _ => None, - }) - }) -} - /// Give the field at `som` in `live`, and every other field bound to the same data node (a /// field inside an area shares its node with one outside, since areas are left out of data /// paths), the value [`form_tree`] would give it from `data` now, so a value change needs no @@ -637,7 +597,7 @@ fn named_in<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> /// node bound there is not a plain field the tree can refresh alone (a radio button, an /// exclusion group, an object the template doesn't have): rebuild the tree. pub fn refresh_value(live: &mut FormNode, tpl: &Template, data: Option<&DataNode>, som: &str) -> Option { - template_field(tpl, som)?; + crate::data::template_field(tpl, som)?; let path = data_path(tpl, som); if path.is_empty() { return None; @@ -673,7 +633,7 @@ pub fn refresh_value(live: &mut FormNode, tpl: &Template, data: Option<&DataNode if c.kind != Some(NodeKind::Field) { return None; } - let f = template_field(tpl, &c.som)?; + let f = crate::data::template_field(tpl, &c.som)?; let value = Walker::plain_field_value(tpl, data, f, &c.som); if c.value != value { c.value = value; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 245e7e3f2..e1b246c6a 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -350,6 +350,55 @@ fn a_date_is_read_from_the_data_in_its_data_picture() { assert_eq!(by("when").value.as_deref(), Some("10/18/2026"), "no data picture: the data holds ISO"); } +#[test] +fn som_paths_name_the_template_field_their_numbers_count_to() { + let xml = r#""#; + let (tpl, _) = parse(xml).unwrap(); + let picture = |som: &str| crate::data::template_field(&tpl, som).and_then(|f| f.data_picture.clone()); + // Same-named siblings count up; each SOM number is its own field. + assert_eq!(picture("form[0].x[0]").as_deref(), Some("first")); + assert_eq!(picture("form[0].x[1]").as_deref(), Some("second")); + assert_eq!(picture("form[0].x[2]"), None, "no third x"); + // The instances of a repeating subform follow its number: all are the one template node. + assert_eq!(picture("form[0].row[0].d[0]").as_deref(), Some("row")); + assert_eq!(picture("form[0].row[3].d[0]").as_deref(), Some("row")); + // An unnamed subform is looked through, as data paths do. + assert_eq!(picture("form[0].u[0]").as_deref(), Some("inner")); +} + +#[test] +fn a_refreshed_field_takes_its_value_from_its_own_template_field() { + // Two text fields named `x` with different defaults; the data holds only the first. + let xml = r#"
a
"#; + let (tpl, _) = parse(xml).unwrap(); + let data = parse_datasets(xml).unwrap(); + let mut live = form_tree(&tpl, Some(&data), &Overrides::default()).root; + let value = |live: &crate::script::FormNode, som: &str| node_at(live, som).map(|n| n.value.clone()); + assert_eq!(value(&live, "form[0].x[1]").as_deref(), Some("second")); + assert_eq!(crate::script::refresh_value(&mut live, &tpl, Some(&data), "form[0].x[1]"), Some(false), "nothing to change"); + assert_eq!(value(&live, "form[0].x[1]").as_deref(), Some("second"), "not the first x's default"); +} + +/// The node of `live` at `som`. +fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate::script::FormNode> { + if live.som == som { + return Some(live); + } + live.children.iter().find_map(|c| node_at(c, som)) +} + #[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 54d123ada..bcaa3c64b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5244,8 +5244,8 @@ milestone = "M12" status = "partial" commands = ["file.open", "file.save", "form.export_data", "form.import_data"] tools = ["doc_open", "form_fill", "doc_save", "doc_export_data", "doc_import_data", "form_set_image"] -evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"] -notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one, which saving writes them in too, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" +evidence = ["crates/xfa/src/tests.rs::the_datasets_packet_is_written_from_the_fields_and_read_back", "crates/xfa/src/tests.rs::a_date_is_read_from_the_data_in_its_data_picture", "crates/engine/src/tests.rs::xfa_dates_are_written_and_read_back_in_their_data_picture", "crates/engine/src/tests.rs::each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso", "crates/xfa/src/tests.rs::som_paths_name_the_template_field_their_numbers_count_to", "crates/xfa/src/tests.rs::a_refreshed_field_takes_its_value_from_its_own_template_field", "crates/xfa/src/tests.rs::data_fills_the_fields_and_adds_rows", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/xfa/src/tests.rs::data_paths_leave_areas_out", "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::data_exports_as_datasets_xml_or_an_xdp_and_imports_from_any_of_them", "crates/xfa/src/tests.rs::a_picture_chosen_for_an_image_field_is_kept_in_the_data_with_its_content_type", "crates/engine/src/tests.rs::xfa_data_exports_imports_and_pictures_round_trip_through_the_session", "crates/engine/src/tests.rs::xfa_data_import_adds_and_removes_rows_of_a_dynamic_form", "crates/automation/tests/automation.rs::xfa_data_files_export_and_import_through_tools"] +notes = "the datasets packet inside the PDF: read on open (values, dates in ISO form, or in the field's data picture (``, such as date{YYYYMMDD}) when it has one holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO), each field its own even beside same-named siblings, which saving writes them in too, check and radio states, extra rows for repeating subforms) and rewritten after every form edit from the fields' SOM paths (/PCSom), with areas left out of data paths as Adobe binds them. Export Data writes it as XML (the data alone, rooted at the form's root data node, as Acrobat does) or an XDP with the whole datasets and a reference to the PDF; Import Data takes an XDP, datasets or data XML file (or a bare data document) as the form's whole data, keeping data descriptions, lays a dynamic form out again from it (rows added and removed, more than 50 rows reported) and runs its calculations, undoable; a static form's fields take the data and the rest are emptied; the file's data is re-serialized with the packet's own prefix and every packet rewrite is parsed back before it is written; XFDF/FDF/CSV still go the AcroForm way. Files are UTF-8, UTF-16 or ISO-8859-1, at most 16 MB and 64 levels deep; pictures at most 8 MB (PNG, JPEG, GIF). Pictures chosen for image fields are kept in the data as base64 with xfa:contentType, as Designer saves them. Missing: explicit bind refs, data descriptions are kept but not checked, no data connection (WSDL/ODBC)" [[feature]] id = "form.xfa-flatten"