diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1c4b92406..b104ef598c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4231,6 +4231,8 @@ fn an_xfa_form_without_fields_that_forbids_dynamic_rendering_keeps_its_pages() { assert!(doc.bytes.len() > plain.len() && doc.bytes.starts_with(&plain[..]), "the layout is a revision"); } +/// A static XFA form's fields take their values from the datasets on opening, and edits are +/// written back there. #[test] fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1")); @@ -4260,6 +4262,56 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A date goes into the XFA data in the field's data picture (``), as other +/// viewers write and read it, and comes back from there when the form is opened again. It used +/// to be written as an ISO date whatever the picture said. +#[test] +fn xfa_dates_are_written_and_read_back_in_their_data_picture() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::bound_date_template(""))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + s.apply(id, Edit::SetFieldValue { name: "due".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "when".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(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].due[0]")), Some("20261018"), "in the field's data picture"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].when[0]")), Some("2026-10-18"), "ISO without one"); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + let value = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone(); + assert_eq!(value("due"), vec!["10/18/2026".to_string()]); + assert_eq!(value("when"), vec!["10/18/2026".to_string()]); +} + +/// Same-named date fields are each written in their own data picture, and a data picture +/// without a day keeps the ISO date. +#[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"); + } +} + +/// Data no field binds to (other namespaces, history) survives an edit and a save. #[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 fc0997facb..07cde40009 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::{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/"; @@ -157,6 +157,113 @@ fn named<'a>(nodes: &'a [Node], name: &str, depth: usize) -> Option<&'a Node> { }) } +/// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as +/// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond +/// years, months, days and separators). +pub(crate) fn date_letters(picture: &str) -> Option { + let inner = picture + .find('{') + .and_then(|a| { + let rest = picture.get(a + 1..)?; + rest.find('}').and_then(|b| rest.get(..b)) + }) + .unwrap_or(picture) + .trim(); + if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { + return None; + } + 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. +pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { + data_pattern.and_then(|p| pattern_to_iso(value, p)).unwrap_or_else(|| value.to_string()) +} + +/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the +/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. +pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { + match data_pattern { + Some(p) => { + let written = iso_to_pattern(&iso, p); + if pattern_to_iso(&written, p).as_deref() == Some(iso.as_str()) { written } else { iso } + } + None => iso, + } +} + +/// 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, last_index), inner) = rest.split_last()?; + let mut children: &[Node] = &tpl.root.children; + 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_at(children, last, *last_index, 0)? { + Node::Field(f) => Some(f), + _ => None, + } +} + +/// 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(data_date_letters) +} + /// What a field holds, for writing the datasets. #[derive(Clone, Debug, PartialEq)] pub enum FieldData { @@ -446,6 +553,8 @@ fn path_of(tpl: Option<&Template>, som: &str) -> DataPath { } } +/// The values the datasets should hold for `fields`, by data path: dates in each field's data +/// picture (or ISO), check and radio states, choice values as saved. fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec { let mut out = Vec::new(); for f in fields { @@ -456,7 +565,11 @@ fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec< } let text = match &f.data { FieldData::Text(t) => match date_pattern_of(doc, f) { - Some(p) => pattern_to_iso(t, &p).unwrap_or_else(|| t.clone()), + Some(p) => match pattern_to_iso(t, &p) { + // The date goes into the data in the field's data picture, or as an ISO date. + Some(iso) => iso_to_data(iso, data_date_pattern(tpl, &som).as_deref()), + None => t.clone(), + }, None => t.clone(), }, FieldData::Check(on) => { @@ -1525,17 +1638,20 @@ pub fn read_values_with(doc: &Document, tpl: Option<&Template>, fields: &[FieldD with_template(doc, tpl, |t| values_at(doc, t, fields)) } +/// The values the document's datasets hold for `fields`, read in each field's data picture and +/// shown in its own pattern; only fields the data mentions. fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> Vec<(String, FieldData)> { let Ok(Some(p)) = crate::read_packets(doc) else { return Vec::new() }; let Some(data) = parse_datasets(&p.xdp) else { return Vec::new() }; let mut out = Vec::new(); for f in fields { - let path = path_of(tpl, &som_of(doc, f)); + let som = som_of(doc, f); + let path = path_of(tpl, &som); let Some(text) = data.value_at(&path) else { continue }; let text = text.as_str(); let value = match &f.data { FieldData::Text(_) => FieldData::Text(match date_pattern_of(doc, f) { - Some(pat) => iso_to_pattern(text, &pat), + Some(pat) => iso_to_pattern(&data_to_iso(text, data_date_pattern(tpl, &som).as_deref()), &pat), None => text.to_string(), }), FieldData::Check(_) => { diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 5b497dd9de..64d3883a92 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -283,6 +283,68 @@ pub fn leave_template(data: &str) -> String { ) } +/// Two date fields shown as `MM/DD/YYYY`: `due` binds its value to the data as `YYYYMMDD` +/// (``), `when` has no data picture, so its data holds ISO dates. +pub fn bound_date_template(data: &str) -> String { + let datasets = if data.is_empty() { + String::new() + } else { + format!("{data}") + }; + format!( + r##" + + +{datasets} + +"## + ) +} + +/// 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 5a501fbf20..7527caab89 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, 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}; @@ -396,13 +396,7 @@ fn is_row(node: &Node) -> bool { /// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. fn date_pattern(picture: Option<&str>) -> String { - let p = picture.unwrap_or(""); - let inner = p.find('{').and_then(|a| p[a + 1..].find('}').map(|b| &p[a + 1..a + 1 + b])).unwrap_or(p); - let inner = inner.trim(); - if inner.is_empty() || inner.chars().any(|c| !"YMDEJ-/., :".contains(c)) { - return "yyyy-mm-dd".into(); - } - inner.replace('E', "d").to_ascii_lowercase() + picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) } /// What scripts read as a field's `formattedValue` when its display picture is `picture`: the @@ -1486,6 +1480,8 @@ impl Layouter<'_> { if *n == 1 { base } else { format!("{base}_{n}") } } + /// Lay out field `f` in `rect`: its caption, its widget of the right kind, and its value + /// from the data. fn draw_field( &mut self, f: &Field, @@ -1672,8 +1668,12 @@ impl Layouter<'_> { } Ui::DateTimeEdit => { let pattern = date_pattern(f.picture.as_deref()); - // Data holds ISO dates; the field shows them in its pattern. - let shown = plain_value.as_ref().map(|v| if data_value.is_some() { iso_to_pattern(v, &pattern) } else { v.clone() }); + // 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 = 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() }); (WidgetKind::Date(pattern), ui, widget_border(f.ui_border.as_ref(), false), shown, f.tooltip.clone()) } Ui::ChoiceList => { diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 327f7fc417..e6d737e036 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -493,6 +493,9 @@ pub struct Field { pub access: Access, /// The edit picture clause (`…`), e.g. `date{YYYY-MM-DD}`. pub picture: Option, + /// The data picture clause (`…`), e.g. `date{YYYYMMDD}`: how the value is + /// written in the data. Without one, dates there are ISO. + pub data_picture: Option, /// `