From 93f90ef760fae73555ff6d41352c18ea545cb9ee Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:45:26 -0400 Subject: [PATCH] M12: XFA dates are written and read in the field's data picture A field's data picture (, such as date{YYYYMMDD}) is how its value is written in the XFA data, but PdfCraft always wrote dates there as ISO (2026-10-18) and read them back as ISO, so a form that keeps its dates as 20261018 got a different format from PdfCraft than from other viewers, and data those viewers wrote was shown as stored. The parser keeps as Field::data_picture. A typed date is still read in the field's own pattern and turned into ISO; it is then written in the data picture when the field has one. Reading goes the other way, on layout and when values are taken from the data of a form laid out before. A value that doesn't match its data picture is read as ISO, so data written by earlier versions still reads. The date-letters rule for XFA picture clauses moves to one helper (date_letters) that the layout's date_pattern now uses too. --- crates/engine/src/tests.rs | 22 +++++++++++ crates/xfa/src/data.rs | 74 ++++++++++++++++++++++++++++++++++-- crates/xfa/src/fixtures.rs | 26 +++++++++++++ crates/xfa/src/layout.rs | 18 ++++----- crates/xfa/src/model.rs | 3 ++ crates/xfa/src/parse.rs | 1 + crates/xfa/src/tests.rs | 16 +++++++- parity/acrobat-features.toml | 4 +- 8 files changed, 147 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41da..c25427fc1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4184,6 +4184,28 @@ 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()]); +} + #[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 d3e1f2c1a..30084d960 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, Template}; use crate::packets::{Encoding, encode}; const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; @@ -157,6 +157,67 @@ 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()) +} + +/// 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. +fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { + match data_pattern { + Some(p) => iso_to_pattern(&iso, p), + None => iso, + } +} + +/// 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> { + 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 mut children: &[Node] = &tpl.root.children; + for (name, _) in inner { + children = match named(children, name, 0)? { + Node::Subform(sf) => &sf.children, + Node::Area(a) => &a.children, + _ => return None, + }; + } + match named(children, last, 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(|f| f.data_picture.as_deref()).and_then(date_letters) +} + /// What a field holds, for writing the datasets. #[derive(Clone, Debug, PartialEq)] pub enum FieldData { @@ -456,7 +517,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) => { @@ -1530,12 +1595,13 @@ fn values_at(doc: &Document, tpl: Option<&Template>, fields: &[FieldDatum]) -> V 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 54bb9bc21..e368a4fa4 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -257,6 +257,32 @@ 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} + +"## + ) +} + /// 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 62d7beedf..1b24e8c8d 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_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()) } fn button_action(scripts: &[Script]) -> Option { @@ -1642,8 +1636,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 = f.data_picture.as_deref().and_then(date_letters); + 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 572675f95..48878bdc6 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, /// `