mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
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:
1 parent
c67c44bf21
commit
61ce0cb010
4 files changed
+224
-8
No files matched your search
+19
-4
@@ -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 } => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user