M6: a multi-select list's picks reach its XFA data as one value element each (form.fill-choice) (#6328)

This commit is contained in:
mtlcl410 authored and GitHub committed 2026-11-09 08:55:29 +08:00
1 parent c67c44bf21
commit 61ce0cb010
4 files changed
+224 -8

No files matched your search

+19 -4
View File
@@ -55,6 +55,10 @@ pub struct DataNode {
pub name: String,
pub text: String,
pub children: Vec<DataNode>,
/// Whether the element is a data group even with no children: marked
/// `xfa:dataNode="dataGroup"` (XFA 3.3 p. 150), or a multi-select list's group emptied of its
/// picks here, which the packet writer marks so.
pub group: bool,
}
/// A path into the data: `(name, index among same-named siblings)` per level.
@@ -150,10 +154,21 @@ impl DataNode {
pub fn apply(&mut self, op: &DataOp) {
match op {
DataOp::Value { path, text } => {
if let Some(n) = self.ensure(path)
&& n.children.is_empty()
{
n.text = text.clone();
if let Some(n) = self.ensure(path) {
// A multi-select list's picks, one `value` each (see `value_at`), told apart
// as the packet writer does: `value` leaves only, or a group holding none.
let picks = if n.children.is_empty() { n.group } else { n.children.iter().all(|c| c.name == "value" && c.children.is_empty()) };
if picks {
n.children = text
.split('\n')
.filter(|v| !v.is_empty())
.map(|v| DataNode { name: "value".into(), text: v.to_string(), ..Default::default() })
.collect();
// Emptied, it is still a group, and takes picks again.
n.group = true;
} else if n.children.is_empty() {
n.text = text.clone();
}
}
}
DataOp::Instances { parent, name, count } => {
+76
View File
@@ -271,6 +271,44 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec<String>) -> Merg
Merged::Changed(out)
}
/// Whether `node` is a multi-select choice list's data group: element children only, every one
/// a `value` holding text alone; or, with no children, an element marked `xfa:dataNode=
/// "dataGroup"`, as a group with no picks is written (XFA 3.3 p. 150).
fn is_value_group(node: roxmltree::Node) -> bool {
let blank = node.children().filter(|c| c.is_text()).all(|c| c.text().is_some_and(|t| t.trim().is_empty()));
let mut elements = node.children().filter(|c| c.is_element()).peekable();
if elements.peek().is_none() {
return blank && marked_data_group(node);
}
blank && elements.all(|c| c.tag_name().name() == "value" && !c.children().any(|g| g.is_element()))
}
/// Whether `node` carries `xfa:dataNode="dataGroup"`.
fn marked_data_group(node: roxmltree::Node) -> bool {
node.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup")
}
/// The edit that marks the data group `node` as one, so that emptied of its picks it still loads
/// as a group and not as a data value (XFA 3.3 p. 150): `xfa:dataNode="dataGroup"` added to its
/// start tag, with the namespace declared there when the packet has no prefix for it.
fn mark_data_group_edit(src: &str, node: roxmltree::Node) -> Option<Edit> {
let range = node.range();
let tag_end = start_tag_end(src, &range)?;
// Before `/>` of an empty tag, else before `>`.
let at = if src.get(..tag_end)?.ends_with('/') { tag_end.checked_sub(1)? } else { tag_end };
let text = match node.lookup_prefix(XFA_DATA_NS) {
Some(prefix) if !prefix.is_empty() => format!(" {prefix}:dataNode=\"dataGroup\""),
_ => {
// A prefix no namespace in scope is bound to, so declaring it rebinds nothing.
let prefix = (0..16)
.map(|i| if i == 0 { "pcxfa".to_string() } else { format!("pcxfa{i}") })
.find(|p| node.lookup_namespace_uri(Some(p)).is_none())?;
format!(" xmlns:{prefix}=\"{XFA_DATA_NS}\" {prefix}:dataNode=\"dataGroup\"")
}
};
Some(Edit { start: at, end: at, text })
}
/// The edits that put `wants` into the `data` element.
fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec<Edit>, warnings: &mut Vec<String>) {
// Element children by local name, per element visited (built once each).
@@ -311,6 +349,44 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
continue;
}
let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
// A multi-select choice list is bound to a data group of `value` elements, one per
// pick, and its value is theirs joined by newlines (XFA 3.3 p. 198); a new pick
// replaces them all, as in Acrobat (p. 199).
if is_value_group(node) {
// Every text segment of each `value`, as `current` reads a leaf (a comment may
// split or lead it).
let held: Vec<String> = node
.children()
.filter(|c| c.is_element())
.map(|c| c.children().filter(|t| t.is_text()).filter_map(|t| t.text()).collect())
.collect();
// Pick by pick: one `value` holding a line break is not two picks.
let wanted: Vec<&str> = w.text.split('\n').filter(|v| !v.is_empty()).collect();
if held.iter().map(String::as_str).eq(wanted.iter().copied()) {
continue;
}
let inner: String = wanted.iter().map(|v| format!("<value>{}</value>", escape(v).0)).collect();
// Emptied, the group keeps saying it is one (XFA 3.3 p. 150).
let needs_mark = wanted.is_empty() && !marked_data_group(node);
let mark = if needs_mark { mark_data_group_edit(src, node) } else { None };
if needs_mark && mark.is_none() {
// No marker can be written (no free prefix): emptied, the group would load
// as a data value, so it keeps its picks and the skip is reported.
skipped(warnings, STRUCTURED, &w.som);
continue;
}
match replace_edit(src, node, &inner) {
Some(e) => {
edits.extend(mark);
edits.push(e);
}
None => skipped(warnings, STRUCTURED, &w.som),
}
if dropped {
skipped(warnings, CONTROL, &w.som);
}
continue;
}
if node.children().any(|c| c.is_element()) {
let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
// Ignore existing XML indentation, but not whitespace in the accepted value.
+6 -4
View File
@@ -1,12 +1,14 @@
use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template};
use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template, XFA_DATA_NS};
fn to_node(n: roxmltree::Node, depth: usize) -> DataNode {
let name = n.tag_name().name().to_string();
let children: Vec<DataNode> =
if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() };
// The text of a leaf is its value; a container's own text is whitespace.
let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::<String>() } else { String::new() };
DataNode { name, text, children }
// The text of a leaf is its value (its text nodes: a comment is not part of it); a
// container's own text is whitespace.
let text = if children.is_empty() { n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>() } else { String::new() };
let group = n.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup");
DataNode { name, text, children, group }
}
/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a
+123
View File
@@ -4815,3 +4815,126 @@ fn a_template_inside_the_data_is_not_the_form() {
let real = "<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form1\"/></template></xdp:xdp>";
assert_eq!(parse(real).unwrap().0.root.common.name.as_deref(), Some("form1"));
}
/// A multi-select choice list is bound to a data group of `value` elements, one per pick, and its
/// value is theirs joined by newlines (XFA 3.3 p. 198). New picks replace the group's `value`
/// elements, values off the list included, as Acrobat discards them (p. 199): the group was left
/// as it was, so the old picks came back on reading, in the datasets and in the scripts' data.
#[test]
fn a_multi_select_lists_data_group_takes_its_new_picks() {
let shell =
crate::fixtures::static_shell("<form1><page1><grains><value>rye</value><value>barley</value><value>wheat</value></grains></page1></form1>");
let mut doc = Document::open(Arc::new(shell)).unwrap();
let path = som_to_path("form1[0].page1[0].grains[0]");
assert!(write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written);
let data = crate::script::data_of(&doc).unwrap();
assert_eq!(data.value_at(&path).as_deref(), Some("wheat\nmillet & oats"));
assert_eq!(data.count(&path, "value"), 2, "one value element per pick");
assert!(!write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written, "the same picks change nothing");
assert!(write_data_value(&mut doc, &path, "").unwrap().written);
assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some(""), "nothing picked");
// Emptied, the group is marked as one (XFA 3.3 p. 150), so it is still written as picks.
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert!(xdp.contains("<grains xfa:dataNode=\"dataGroup\"></grains>"), "{xdp}");
assert!(
write_data_value(
&mut doc,
&path,
"rye
millet"
)
.unwrap()
.written
);
assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2, "picks again, not text");
// A pick split or led by a comment is read whole: the same pick is no change, and clearing
// a pick that reads as empty through its first segment alone still clears it.
let commented =
crate::fixtures::static_shell("<form1><page1><grains><value><!-- a -->rye</value><value>whe<!-- b -->at</value></grains></page1></form1>");
let mut doc = Document::open(Arc::new(commented)).unwrap();
assert!(
!write_data_value(
&mut doc,
&path,
"rye
wheat"
)
.unwrap()
.written,
"the same picks, read past the comments"
);
assert_eq!(
crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(),
Some(
"rye
wheat"
),
"and read back without them"
);
assert!(write_data_value(&mut doc, &path, "").unwrap().written, "cleared");
assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 0);
// Where `xfa` names another namespace and `pcxfa` is taken too, the marker's prefix is a free
// one, declared on the group, so no attribute in scope changes meaning.
let shadowed = crate::fixtures::static_shell(
"<form1><page1><grains xmlns:xfa=\"urn:other\" xmlns:pcxfa=\"urn:taken\" pcxfa:note=\"x\"><value>rye</value></grains></page1></form1>",
);
let mut doc = Document::open(Arc::new(shadowed)).unwrap();
assert!(write_data_value(&mut doc, &path, "").unwrap().written);
let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
assert!(xdp.contains("xmlns:pcxfa1=\"http://www.xfa.org/schema/xfa-data/1.0/\" pcxfa1:dataNode=\"dataGroup\""), "{xdp}");
assert!(xdp.contains("xmlns:pcxfa=\"urn:taken\" pcxfa:note=\"x\""), "the taken prefix keeps its namespace: {xdp}");
// With every candidate prefix taken, no marker can be written: the group keeps its pick
// and the skip is reported, rather than emptied into what would load as a data value.
let taken: String = (0..16).map(|i| if i == 0 { " xmlns:pcxfa=\"urn:t\"".to_string() } else { format!(" xmlns:pcxfa{i}=\"urn:t\"") }).collect();
let crowded =
crate::fixtures::static_shell(&format!("<form1><page1><grains xmlns:xfa=\"urn:other\"{taken}><value>rye</value></grains></page1></form1>"));
let mut doc = Document::open(Arc::new(crowded)).unwrap();
let r = write_data_value(&mut doc, &path, "").unwrap();
assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured content")), "{:?}", r.warnings);
assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some("rye"));
// One `value` whose text holds a line break is one pick, not the two it would join to.
let one = crate::fixtures::static_shell(
"<form1><page1><grains><value>one
two</value></grains></page1></form1>",
);
let mut doc = Document::open(Arc::new(one)).unwrap();
assert!(
write_data_value(
&mut doc, &path, "one
two"
)
.unwrap()
.written,
"two picks replace the one"
);
assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2);
// The in-memory data, which scripts read before the packet is written, follows the same rule.
let mut tree = crate::script::data_of(
&Document::open(Arc::new(crate::fixtures::static_shell("<form1><page1><grains><value>rye</value></grains></page1></form1>"))).unwrap(),
)
.unwrap();
tree.apply(&DataOp::Value { path: path.clone(), text: "millet\nwheat".into() });
assert_eq!(tree.value_at(&path).as_deref(), Some("millet\nwheat"));
// Emptied in memory, the group still takes picks, not text.
tree.apply(&DataOp::Value { path: path.clone(), text: String::new() });
assert_eq!((tree.count(&path, "value"), tree.value_at(&path).as_deref()), (0, Some("")));
tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() });
assert_eq!((tree.count(&path, "value"), tree.text_at(&path)), (2, Some("")), "picks again, not text");
assert_eq!(tree.value_at(&path).as_deref(), Some("rye\nmillet"));
let tree_of = |data: &str| crate::script::data_of(&Document::open(Arc::new(crate::fixtures::static_shell(data))).unwrap()).unwrap();
// A group read emptied, as the packet writer marks it, takes picks the same way.
let mut tree = tree_of("<form1><page1><grains xfa:dataNode=\"dataGroup\"/></page1></form1>");
tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() });
assert_eq!(tree.count(&path, "value"), 2);
// A node holding anything but `value` leaves is not a list's picks: it is left as it was,
// as the packet writer leaves it (and reports the skip).
for structured in [
"<form1><page1><grains><value>rye</value><note>x</note></grains></page1></form1>",
"<form1><page1><grains><value><b>rye</b></value></grains></page1></form1>",
] {
let mut tree = tree_of(structured);
let before = tree.clone();
tree.apply(&DataOp::Value { path: path.clone(), text: "millet".into() });
assert_eq!(tree, before, "{structured}");
}
}