M12: XFA dates are written and read in the field's data picture

A field's data picture (<bind><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 <bind><picture> 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.
This commit is contained in:
Eric Kaden
2026-10-18 23:45:26 -04:00
parent 4db6ac4ae2
commit 93f90ef760
8 changed files with 147 additions and 17 deletions
+22
View File
@@ -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 (`<bind><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.
+70 -4
View File
@@ -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<String> {
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<String> {
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(_) => {
+26
View File
@@ -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`
/// (`<bind><picture>`), `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!("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets>")
};
format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
<pageSet><pageArea name="front"><contentArea x="0.5in" y="0.5in" w="7.5in" h="10in"/><medium short="8.5in" long="11in"/></pageArea></pageSet>
<subform name="page" layout="tb">
<field name="due" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{{MM/DD/YYYY}}</picture></ui><value><date/></value><bind match="once"><picture>date{{YYYYMMDD}}</picture></bind></field>
<field name="when" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{{MM/DD/YYYY}}</picture></ui><value><date/></value></field>
</subform>
</subform>
</template>
{datasets}
</xdp:xdp>
"##
)
}
/// A 2 × 2 RGBA PNG: red, green / blue, transparent.
pub fn tiny_png() -> Vec<u8> {
TINY_PNG.to_vec()
+8 -10
View File
@@ -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<Action> {
@@ -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 => {
+3
View File
@@ -493,6 +493,9 @@ pub struct Field {
pub access: Access,
/// The edit picture clause (`<ui>…<picture>`), e.g. `date{YYYY-MM-DD}`.
pub picture: Option<String>,
/// The data picture clause (`<bind>…<picture>`), e.g. `date{YYYYMMDD}`: how the value is
/// written in the data. Without one, dates there are ISO.
pub data_picture: Option<String>,
/// `<event activity="…"><script>`: click, change, exit, initialize, …
pub scripts: Vec<Script>,
/// `<calculate><script>`: the value is the script's result.
+1
View File
@@ -470,6 +470,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
_ => Access::Open,
},
picture: ui.and_then(|u| child(u, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
data_picture: child(n, "bind").and_then(|b| child(b, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()),
scripts,
calculate: child(n, "calculate").and_then(|c| child(c, "script")).map(|s| parse_script(s, "calculate")),
validate: validate_el.and_then(|v| child(v, "script")).map(|s| parse_script(s, "validate")),
+15 -1
View File
@@ -7,7 +7,9 @@ use crate::data::{
DatasetsWrite, FieldData, FieldDatum, build_data, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, som_to_path, write_datasets,
};
use crate::data::{add_data_instances, remove_data_instance, write_data_value};
use crate::fixtures::{TINY_GIF, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png};
use crate::fixtures::{
TINY_GIF, bound_date_template, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png,
};
use crate::layout::Item;
use crate::layout::WidgetKind;
use crate::model::*;
@@ -336,6 +338,18 @@ fn dates_convert_between_iso_and_acrobat_patterns() {
assert_eq!(pattern_to_iso("hello", "mm/dd/yyyy"), None);
}
/// A date field's data picture (`<bind><picture>`) is how its value is written in the data:
/// `due` keeps `20261018` there and shows `10/18/2026`. The stored text used to be shown as it
/// was, because the data was taken to hold ISO dates only.
#[test]
fn a_date_is_read_from_the_data_in_its_data_picture() {
let form = layout_xml(&bound_date_template("<form><page><due>20261018</due><when>2026-10-18</when></page></form>")).unwrap();
let all: Vec<&Widget> = form.pages.iter().flat_map(widgets).collect();
let by = |n: &str| all.iter().find(|w| w.name == n).unwrap_or_else(|| panic!("no widget {n}"));
assert_eq!(by("due").value.as_deref(), Some("10/18/2026"));
assert_eq!(by("when").value.as_deref(), Some("10/18/2026"), "no data picture: the data holds ISO");
}
#[test]
fn data_fills_the_fields_and_adds_rows() {
let form = layout_xml(&template_with_data(1, DATA)).unwrap();
+2 -2
View File
@@ -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::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, 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/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 (`<bind><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)"
[[feature]]
id = "form.xfa-flatten"