diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index cb3b80a0c7..09c8252fa8 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -55,6 +55,10 @@ pub struct DataNode { pub name: String, pub text: String, pub children: Vec, + /// 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 } => { diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs index 0aefc634b5..0d573c71a9 100644 --- a/crates/xfa/src/data/merge.rs +++ b/crates/xfa/src/data/merge.rs @@ -271,6 +271,44 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> 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 { + 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, warnings: &mut Vec) { // 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 = 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!("{}", 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. diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs index 71f089112e..fd20b39d81 100644 --- a/crates/xfa/src/data/path.rs +++ b/crates/xfa/src/data/path.rs @@ -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 = 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::() } 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::() } 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 diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index d688c8312c..43d6633c56 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4815,3 +4815,126 @@ fn a_template_inside_the_data_is_not_the_form() { let real = ""; 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("ryebarleywheat"); + 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(""), "{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("ryewheat"); + 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( + "rye", + ); + 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!("rye")); + 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( + "one +two", + ); + 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("rye"))).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(""); + 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 [ + "ryex", + "rye", + ] { + 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}"); + } +}