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##"
+
+
+
+
+
+ date{{MM/DD/YYYY}}date{{YYYYMMDD}}
+ date{{MM/DD/YYYY}}
+
+
+
+{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,
/// `