Merge PR #3397: M12: XFA dates are written and read in the field's data picture

crates/engine/src/tests.rs: union of #3180's new test and #3397's doc comment on the next test.
crates/xfa/src/layout.rs: kept #3782's formatted_value (it calls date_pattern, now built on date_letters) and button_action doc.
crates/xfa/src/parse.rs: parse_field doc comment from #3397 (names the data picture).
crates/xfa/src/script.rs: field() doc from #3582 with #3397's "as scripts read it".
crates/xfa/src/tests.rs: union of fixture imports and of the new tests; one doc comment each for the date-conversion and data-fill tests.
This commit is contained in:
aiongg
2026-10-22 01:45:58 +00:00
9 changed files with 365 additions and 67 deletions
+52
View File
@@ -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("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"));
@@ -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 (`<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()]);
}
/// 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.
+120 -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, 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<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())
}
/// 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<String> {
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<String> {
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<Want> {
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(_) => {
+62
View File
@@ -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`
/// (`<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>
"##
)
}
/// 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!("<xfa:datasets xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\"><xfa:data>{data}</xfa:data></xfa:datasets>")
};
let field = |name: &str, bind: &str| {
format!(
r#"<field name="{name}" w="2in" h="0.3in"><ui><dateTimeEdit/><picture>date{{MM/DD/YYYY}}</picture></ui><value><date/></value><bind match="once"><picture>date{{{bind}}}</picture></bind></field>"#
)
};
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">
{}
{}
{}
</subform>
</subform>
</template>
{datasets}
</xdp:xdp>
"##,
field("due", "YYYYMMDD"),
field("due", "DDMMYYYY"),
field("month", "YYYYMM")
)
}
/// A 2 × 2 RGBA PNG: red, green / blue, transparent.
pub fn tiny_png() -> Vec<u8> {
TINY_PNG.to_vec()
+10 -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_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 => {
+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.
+3 -2
View File
@@ -407,8 +407,8 @@ fn parse_items(n: XmlNode, warnings: &mut Vec<String>) -> (Vec<String>, Vec<Stri
(shown, saved)
}
/// A `<field>` element as the template model: its widget, caption, value, items, pictures,
/// access and scripts.
/// A `<field>` element as the model's [`Field`]: its widget kind, caption, value, items,
/// pictures (edit and data), scripts and access.
fn parse_field(n: XmlNode, warnings: &mut Vec<String>) -> Field {
let ui = child(n, "ui");
let ui_el = ui.and_then(|u| u.children().find(|c| c.is_element() && c.tag_name().name() != "extras" && c.tag_name().name() != "picture"));
@@ -472,6 +472,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")),
+16 -47
View File
@@ -245,7 +245,7 @@ impl Walker<'_> {
/// The live node of field `f`, child `index` of `parent_som`, with its scripts recorded. In an
/// exclusion group (`radio` is the group's selected value) its value is its on value when picked,
/// else empty; elsewhere it is its data value (as saved) or its template default.
/// else empty; elsewhere it is its data value (as saved, as scripts read it) or its template default.
fn field(&mut self, f: &Field, parent_som: &str, index: usize, radio: Option<&str>) -> FormNode {
let som = child_som(parent_som, f.common.name.as_deref().or(Some("field")), index);
let data_value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
@@ -255,7 +255,7 @@ impl Walker<'_> {
let on = f.items.first().cloned().unwrap_or_default();
if selected == on { on } else { String::new() }
}
(None, Some(v)) => f.saved_value(&v),
(None, Some(v)) => f.saved_value(&Walker::as_scripts_read(f, &v)),
(None, None) => f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(),
};
self.push_events(&som, &f.scripts, f.calculate.as_ref(), f.validate.as_ref(), f.validate_message.as_deref());
@@ -276,12 +276,19 @@ impl Walker<'_> {
}
}
/// What [`Self::field`] gives a field outside an exclusion group: its data value (as saved)
/// or its template default.
/// A value the data holds as scripts read it: a date the field's data picture writes in
/// another form (`20261018`) is the ISO date (`2026-10-18`) another viewer gives scripts as
/// `rawValue`. Other values, and dates that don't match the picture, are as they are.
fn as_scripts_read(f: &Field, v: &str) -> String {
crate::data::data_to_iso(v, crate::data::data_date_letters(f).as_deref())
}
/// What [`Self::field`] gives a field outside an exclusion group: its data value (as saved,
/// as scripts read it) or its template default.
fn plain_field_value(tpl: &Template, data: Option<&DataNode>, f: &Field, som: &str) -> String {
let data_value = data.and_then(|d| d.value_at(&data_path(tpl, som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
match data_value {
Some(v) => f.saved_value(&v),
Some(v) => f.saved_value(&Walker::as_scripts_read(f, &v)),
None => f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(),
}
}
@@ -611,46 +618,6 @@ pub fn template_of(doc: &Document) -> Result<Template, XfaError> {
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
@@ -659,11 +626,13 @@ 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<bool> {
template_field(tpl, som)?;
crate::data::template_field(tpl, som)?;
let path = data_path(tpl, som);
if path.is_empty() {
return None;
}
/// Down the one branch of `n` that `path` leads to from segment `pos`, giving each field
/// bound there its value from `data`; `None` when something there needs a rebuild.
fn walk(
n: &mut FormNode,
tpl: &Template,
@@ -695,7 +664,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;
+97 -2
View File
@@ -8,7 +8,8 @@ use crate::data::{
};
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, unnamed_hidden_template,
TINY_GIF, bound_date_template, fields_template, scripted_template, shell, static_shell, template, template_with_data, tiny_png,
unnamed_hidden_template,
};
use crate::layout::Item;
use crate::layout::WidgetKind;
@@ -375,7 +376,101 @@ fn display_pictures_are_parsed_apart_from_edit_pictures() {
assert_eq!(days.display_picture, None);
}
/// The datasets fill the fields they bind to and add the rows the data holds.
/// 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");
}
/// A SOM path names the template field its segment numbers count to, repeating rows included.
#[test]
fn som_paths_name_the_template_field_their_numbers_count_to() {
let xml = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form">
<field name="x"><bind><picture>first</picture></bind></field>
<subform name="row"><occur min="1" max="-1"/><field name="d"><bind><picture>row</picture></bind></field></subform>
<field name="x"><bind><picture>second</picture></bind></field>
<subform><field name="u"><bind><picture>inner</picture></bind></field></subform>
</subform>
</template></xdp:xdp>"#;
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"));
}
/// Refreshing a value in place uses the field's own template field, not a same-named one's.
#[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#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form">
<field name="x"><ui><textEdit/></ui><value><text>first</text></value></field>
<field name="x"><ui><textEdit/></ui><value><text>second</text></value></field>
</subform>
</template><xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><x>a</x></form></xfa:data></xfa:datasets></xdp:xdp>"#;
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");
}
/// Scripts read a date as the ISO date another viewer gives them as `rawValue`
/// (`2026-10-18`), whatever the field's data picture writes in the data (`20261018`), so a
/// script's `String(rawValue).length == 10` holds. A field without a data picture keeps the
/// data's ISO date, and refreshing a value in place reads it the same way.
#[test]
fn scripts_read_a_date_as_iso_whatever_its_data_picture_writes() {
let xml = bound_date_template("<form><page><due>20261018</due><when>2026-10-18</when></page></form>");
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].page[0].due[0]").as_deref(), Some("2026-10-18"));
assert_eq!(value(&live, "form[0].page[0].when[0]").as_deref(), Some("2026-10-18"));
let later = parse_datasets(&bound_date_template("<form><page><due>20261019</due><when>2026-10-18</when></page></form>")).unwrap();
assert_eq!(crate::script::refresh_value(&mut live, &tpl, Some(&later), "form[0].page[0].due[0]"), Some(true));
assert_eq!(value(&live, "form[0].page[0].due[0]").as_deref(), Some("2026-10-19"));
}
/// 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))
}
/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads
/// back as written.
#[test]
fn a_date_its_data_picture_cant_hold_is_written_as_iso() {
use crate::data::{data_to_iso, iso_to_data};
assert_eq!(iso_to_data("2026-01-02".into(), Some("yymmdd")), "260102");
// A two-digit year reads back as 1950 to 2049: 2050 and 1949 keep ISO, and read back as written.
for iso in ["2050-01-02", "1949-12-31"] {
assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), iso);
assert_eq!(data_to_iso(iso, Some("yymmdd")), iso);
}
}
/// The datasets fill the fields and add the rows of repeating subforms.
#[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/xfa/src/tests.rs::scripts_read_a_date_as_iso_whatever_its_data_picture_writes", "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::a_date_its_data_picture_cant_hold_is_written_as_iso", "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 holding a whole date (one without a day, such as date{YYYYMM}, keeps ISO, as does a date the picture can't hold, such as 2050 in a two-digit year), each field its own even beside same-named siblings, which saving writes them in too, while scripts still read the ISO date as rawValue, as another viewer gives it, 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"