diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 2dd652263d..4734b52de3 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -9530,3 +9530,59 @@ fn an_opening_reset_reaches_a_row_added_while_opening() {
assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added");
assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].item[0]")).unwrap_or(""), "", "reset in the data");
}
+
+/// A reset in an opening script reaches an exclusion group of a row an earlier opening script
+/// added, before the row has widgets: the group's data goes back to the on value of its default
+/// button, and stays so when the form is saved.
+#[test]
+fn an_opening_reset_reaches_an_exclusion_group_of_a_row_added_while_opening() {
+ let group = r#"AAB"#;
+ let tpl = pdfcraft_xfa::fixtures::scripted_template()
+ .replace(
+ "if (qty.rawValue === null) qty.rawValue = 2;",
+ r#"if (qty.rawValue === null) qty.rawValue = 2; table._row.addInstance(1);"#,
+ )
+ .replace(r#""#, &format!(r#"{group}"#))
+ .replace(
+ r#""#,
+ r#""#,
+ );
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "{:?}", out.errors);
+ let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 2, "the row was added");
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0].row[1].pick[0]")).unwrap_or(""), "A", "group reset in the data");
+}
+
+/// A reset in an opening script clears a multi-select list of a hidden subform (no widget)
+/// whose picks the data holds as `value` elements: its empty default leaves no pick in the
+/// saved data, and the rest of the data stays as it was.
+#[test]
+fn an_opening_reset_clears_structured_picks_of_a_field_without_a_widget() {
+ let tpl = pdfcraft_xfa::fixtures::scripted_template()
+ .replace(
+ r#""#,
+ r#"abc"#,
+ )
+ .replace(
+ "if (qty.rawValue === null) qty.rawValue = 2;",
+ r#"if (qty.rawValue === null) qty.rawValue = 2; xfa.host.resetData("form.page1.details.tags");"#,
+ )
+ .replace(
+ "",
+ r#"
+"#,
+ );
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("tags.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)), None).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.errors.is_empty(), "{:?}", out.errors);
+ let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
+ let data = pdfcraft_xfa::data_of(&cos).unwrap();
+ assert_eq!(data.value_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].tags[0]")).unwrap_or_default(), "", "picks reset in the data");
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].details[0].note[0]")), Some("kept"));
+ assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].qty[0]")), Some("3"));
+}
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index d0b674bc04..3d163ca5f7 100644
--- a/crates/engine/src/xfa.rs
+++ b/crates/engine/src/xfa.rs
@@ -233,15 +233,52 @@ fn plain_som(som: &str) -> String {
out
}
-/// Does `name` from a script (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a container
-/// such as `table`) name this SOM or one of its ancestors?
-fn som_matches(som: &str, name: &str) -> bool {
- let plain = plain_som(som);
- let n = plain_som(name.trim());
- let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
- let want: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
- let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
- !want.is_empty() && have.windows(want.len()).any(|w| w == want.as_slice())
+/// The names a script passed to a reset (`qty`, `page1.qty`, `xfa.form.form.page1.qty`, or a
+/// container such as `table`), indexed, so matching an object against them costs the same
+/// however many names there are.
+struct ResetTargets {
+ /// Each name's steps without indexes, joined by `.`.
+ wants: HashSet,
+ /// The most steps of a name.
+ longest: usize,
+}
+
+impl ResetTargets {
+ fn new(names: &[String]) -> Self {
+ let mut wants = HashSet::new();
+ let mut longest = 0;
+ for name in names {
+ let n = plain_som(name.trim());
+ let n = n.strip_prefix("xfa.form.").or_else(|| n.strip_prefix("$form.")).unwrap_or(&n);
+ let steps: Vec<&str> = n.split('.').filter(|s| !s.is_empty()).collect();
+ if !steps.is_empty() {
+ longest = longest.max(steps.len());
+ wants.insert(steps.join("."));
+ }
+ }
+ ResetTargets { wants, longest }
+ }
+
+ /// Does a name name this SOM or one of its ancestors: is it a run of the SOM's steps? The
+ /// runs are looked up, at most [`Self::longest`] steps each, so the work depends on the
+ /// SOM's depth and not on how many names there are.
+ fn matches(&self, som: &str) -> bool {
+ let plain = plain_som(som);
+ let have: Vec<&str> = plain.split('.').filter(|s| !s.is_empty()).collect();
+ for start in 0..have.len() {
+ let mut run = String::new();
+ for (k, step) in have.iter().skip(start).take(self.longest).enumerate() {
+ if k > 0 {
+ run.push('.');
+ }
+ run.push_str(step);
+ if self.wants.contains(&run) {
+ return true;
+ }
+ }
+ }
+ false
+ }
}
/// A generated field as the runner tracks it: kind, value and on state.
@@ -585,22 +622,20 @@ impl<'a, 'b> Runner<'a, 'b> {
fn reset(&mut self, names: &[String]) -> Result<(), String> {
self.flush()?;
let by_som = self.by_som().clone();
- let listed: Option> = if names.is_empty() {
- None
- } else {
- Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect())
- };
- // Fields of the live form with no widget yet (a row an opening script added, before the
- // form is laid out again): their data goes back to the template's default too, or the
- // value would stay and be laid out and saved.
+ let targets = (!names.is_empty()).then(|| ResetTargets::new(names));
+ let wanted = |som: &str| targets.as_ref().is_none_or(|t| t.matches(som));
+ let listed: Option> = targets.as_ref().map(|_| by_som.iter().filter(|(som, _)| wanted(som)).map(|(_, f)| f.clone()).collect());
+ // Fields and exclusion groups of the live form with no widget yet (a row an opening
+ // script added, before the form is laid out again): their data goes back to the
+ // template's default too, or the value would stay and be laid out and saved.
let unshown: Vec = {
let live = self.live();
let mut out = Vec::new();
let mut stack: Vec<&pdfcraft_xfa::FormNode> = vec![&live.root];
while let Some(n) = stack.pop() {
- if n.kind == Some(pdfcraft_xfa::NodeKind::Field)
+ if matches!(n.kind, Some(pdfcraft_xfa::NodeKind::Field | pdfcraft_xfa::NodeKind::ExclGroup))
&& !by_som.contains_key(&n.som)
- && (names.is_empty() || names.iter().any(|name| som_matches(&n.som, name)))
+ && wanted(&n.som)
{
out.push(n.som.clone());
}
@@ -611,10 +646,7 @@ impl<'a, 'b> Runner<'a, 'b> {
for som in unshown {
let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue };
let path = pdfcraft_xfa::data_path(self.tpl, &som);
- if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
- self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
- self.changes.values += 1;
- }
+ self.reset_data(path, text);
}
if listed.as_ref().is_some_and(Vec::is_empty) {
self.invalidate();
@@ -623,6 +655,7 @@ impl<'a, 'b> Runner<'a, 'b> {
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
self.fields = None;
let infos = self.fields().clone();
+ let listed: Option> = listed.as_ref().map(|l| l.iter().collect());
for (som, name) in &by_som {
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
continue;
@@ -632,16 +665,21 @@ impl<'a, 'b> Runner<'a, 'b> {
continue;
}
let path = pdfcraft_xfa::data_path(self.tpl, som);
- let text = data_text(info);
- if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text {
- self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
- self.changes.values += 1;
- }
+ self.reset_data(path, data_text(info));
}
self.invalidate();
Ok(())
}
+ /// Put a reset's `text` in the data at `path`, unless it already holds it: a multi-select
+ /// list's picks (`value` elements) are compared as its lines, and replaced.
+ fn reset_data(&mut self, path: pdfcraft_xfa::DataPath, text: String) {
+ if self.data.as_ref().and_then(|d| d.value_at(&path)).unwrap_or_default() != text {
+ self.data_op(pdfcraft_xfa::DataOp::Reset { path, text });
+ self.changes.values += 1;
+ }
+ }
+
/// Apply what a script of `ev` did. Returns whether the form has to be laid out again.
fn apply_effects(&mut self, ev: &ScriptEvent, effects: &[XfaEffect]) -> Result {
let mut relayout = false;
@@ -1079,14 +1117,36 @@ mod tests {
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
}
+ /// Matching objects against a reset's names costs the same however many names there are:
+ /// 5,000 objects against 50,000 names that miss them all is 250 million comparisons when
+ /// each object is compared with every name.
+ #[test]
+ fn matching_many_objects_against_many_reset_names_is_bounded() {
+ let names: Vec = (0..50_000).map(|i| format!("form1.page1.other{i}")).collect();
+ let started = std::time::Instant::now();
+ let targets = ResetTargets::new(&names);
+ let hits = (0..5_000).filter(|i| targets.matches(&format!("form1[0].page1[0].table[0].row[{i}].item[0]"))).count();
+ assert_eq!(hits, 0);
+ assert!(targets.matches("form1[0].page1[0].other49999[0]"));
+ assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
+ }
+
#[test]
fn script_names_match_indexed_som_paths() {
+ let som_matches = |som: &str, name: &str| ResetTargets::new(&[name.to_string()]).matches(som);
assert!(som_matches("form[0].page1[0].qty[0]", "qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "page1.qty"));
assert!(som_matches("form[0].page1[0].qty[0]", "xfa.form.form.page1.qty"));
+ assert!(som_matches("form[0].page1[0].qty[0]", "$form.form.page1.qty"));
assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table"));
+ assert!(som_matches("form[0].page1[0].table[0].row[2].amount[0]", "table.row[0].amount"));
assert!(!som_matches("form[0].page1[0].qty[0]", "qt"));
assert!(!som_matches("form[0].page1[0].price[0]", "qty"));
+ assert!(!som_matches("form[0].page1[0].qty[0]", "page1.page1.qty"));
+ assert!(!som_matches("form[0].page1[0].qty[0]", " . "));
+ assert!(!ResetTargets::new(&[]).matches("form[0].qty[0]"));
+ let many = ResetTargets::new(&["price".into(), "page1.qty".into()]);
+ assert!(many.matches("form[0].page1[0].qty[0]") && many.matches("form[0].page2[0].price[0]"));
assert_eq!(
split_instance(&Template::default(), "form[0].table[0].row[2]").map(|(p, ps, n, i)| (p.len(), ps, n, i)),
Some((2, "form[0].table[0]".into(), "row".into(), 2))
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
index cca4cb4881..c62a260120 100644
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -5485,3 +5485,23 @@ fn locked_button_export_edits_leave_the_document_unchanged() {
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
}
+
+/// A reset of named fields costs the same per field however many are named: resetting 60,000
+/// fields by name is not 3.6 billion name comparisons.
+#[test]
+fn resetting_many_named_fields_is_not_quadratic() {
+ const N: usize = 60_000;
+ let kids: String = (0..N).map(|i| format!("{} 0 R ", i + 4)).collect();
+ let mut objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 3 0 R >>".into(),
+ "<< /Type /Pages /Kids [] /Count 0 >>".into(),
+ format!("<< /Fields [{kids}] >>"),
+ ];
+ objs.extend((0..N).map(|i| format!("<< /FT /Tx /T (f{i}) /V (a) /DV (a) >>")));
+ let mut doc = document(&objs);
+ let names: Vec = (0..N).map(|i| format!("f{i}")).collect();
+ let started = std::time::Instant::now();
+ assert_eq!(reset(&mut doc, Some(&names)).unwrap(), 0);
+ assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
+ assert!(matches!(reset(&mut doc, Some(&["f0".to_string(), "nope".to_string()])), Err(FormError::NoSuchField(n)) if n == "nope"));
+}
diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs
index e8615567b6..24fc61e2ce 100644
--- a/crates/forms/src/values.rs
+++ b/crates/forms/src/values.rs
@@ -631,13 +631,16 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result = names.map(|_| all.iter().map(|f| f.name.as_str()).collect()).unwrap_or_default();
if let Some(n) = names
- && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n))
+ && let Some(missing) = n.iter().find(|n| !have.contains(n.as_str()))
{
return Err(FormError::NoSuchField(missing.clone()));
}
+ let named: Option> = names.map(|n| n.iter().map(String::as_str).collect());
let mut changed = 0;
- for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) {
+ for f in all.iter().filter(|f| named.as_ref().is_none_or(|n| n.contains(f.name.as_str()))) {
if f.value == f.default {
continue;
}
diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs
index 7c387c44ef..6fb29fd95a 100644
--- a/crates/xfa/src/data.rs
+++ b/crates/xfa/src/data.rs
@@ -34,8 +34,8 @@ pub use dates::{iso_to_pattern, pattern_to_iso};
pub(crate) use import::MAX_IMPORT_BYTES;
pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
pub use passwords::{clear_password_values, has_password_values};
-pub(crate) use path::template_field;
pub use path::{data_path, parse_datasets, som_to_path};
+pub(crate) use path::{template_field, template_object};
pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with};
#[cfg(test)]
pub(crate) use write::ENSURED;
@@ -83,11 +83,25 @@ impl DataNode {
/// elements (one way a multi-select list's picks are stored), those on separate lines.
pub fn value_at(&self, path: &[(String, usize)]) -> Option {
let n = self.get(path)?;
- if n.children.is_empty() || !n.children.iter().all(|c| c.name == "value") {
+ if !n.holds_picks() {
return Some(n.text.clone());
}
- Some(n.children.iter().map(|c| c.text.trim()).filter(|t| !t.is_empty()).take(1000).collect::>().join("\n"))
+ Some(picks_of(n.children.iter().map(|c| c.text.as_str())).collect::>().join("\n"))
}
+
+ /// Whether this node holds a multi-select list's picks: children, all `value` elements.
+ pub(crate) fn holds_picks(&self) -> bool {
+ !self.children.is_empty() && self.children.iter().all(|c| c.name == "value")
+ }
+}
+
+/// Most picks a multi-select value is read or written with.
+pub(crate) const MAX_PICKS: usize = 1000;
+
+/// The picks among `texts` (`value` elements, or the lines of a value): non-empty, trimmed, at
+/// most [`MAX_PICKS`].
+pub(crate) fn picks_of<'a>(texts: impl Iterator) -> impl Iterator {
+ texts.map(str::trim).filter(|t| !t.is_empty()).take(MAX_PICKS)
}
/// One change to the data, for [`write_data_ops`].
@@ -100,6 +114,11 @@ pub enum DataOp {
Instances { parent: DataPath, name: String, count: usize },
/// Take the node at `path` out (a script removed a row).
Remove(DataPath),
+ /// A reset to `text`: as [`DataOp::Value`], and also into a node holding a multi-select
+ /// list's picks (children, all `value` elements), which then holds one `value` per line of
+ /// `text`, none when it is empty. A node with other children is left as it is, as
+ /// [`DataOp::Value`] leaves it.
+ Reset { path: DataPath, text: String },
}
impl DataNode {
@@ -131,6 +150,15 @@ impl DataNode {
n.text = text.clone();
}
}
+ DataOp::Reset { path, text } => {
+ let Some(n) = self.ensure(path) else { return };
+ if n.children.is_empty() {
+ n.text = text.clone();
+ } else if n.holds_picks() {
+ n.children =
+ picks_of(text.lines()).map(|t| DataNode { name: "value".into(), text: t.to_string(), children: Vec::new() }).collect();
+ }
+ }
DataOp::Instances { parent, name, count } => {
if let Some(&last) = count.checked_sub(1).as_ref() {
let mut path = parent.clone();
diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs
index 006f4ff73b..0f3529bb6d 100644
--- a/crates/xfa/src/data/import.rs
+++ b/crates/xfa/src/data/import.rs
@@ -166,7 +166,7 @@ pub fn write_data_image(
}
let ct: String = content_type.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '.' | '+')).take(64).collect();
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
- let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true }];
+ let ensure = [Want { som: String::new(), path: path.clone(), text: String::new(), ensure: true, picks: false }];
rewrite(doc, &[Op::Values(&ensure), Op::Picture(path, &ct, &b64)], reuse)
}
diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs
index 0aefc634b5..f13f59f93f 100644
--- a/crates/xfa/src/data/merge.rs
+++ b/crates/xfa/src/data/merge.rs
@@ -311,6 +311,26 @@ 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();
+ if w.picks && holds_picks(node) {
+ // A reset of a multi-select list's picks: one `value` per line, none for an
+ // empty value, unless the node already holds those.
+ let have: Vec<&str> = super::picks_of(node.children().filter(|c| c.is_element()).map(|c| c.text().unwrap_or(""))).collect();
+ let want: Vec<&str> = super::picks_of(w.text.lines()).collect();
+ if have == want {
+ continue;
+ }
+ // The first pick's own tag, prefix included.
+ let tag = node.children().find(|c| c.is_element()).and_then(|c| qname(src, &c.range())).unwrap_or("value");
+ let inner: String = want.iter().map(|p| format!("<{tag}>{}{tag}>", escape(p).0)).collect();
+ match replace_edit(src, node, &inner) {
+ Some(e) => 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.
@@ -368,6 +388,13 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
}
}
+/// Whether `node` holds a multi-select list's picks: element children, all `value` elements
+/// (as [`DataNode::holds_picks`](super::DataNode::holds_picks) reads the parsed data).
+fn holds_picks(node: roxmltree::Node) -> bool {
+ let mut kids = node.children().filter(|c| c.is_element()).peekable();
+ kids.peek().is_some() && kids.all(|c| c.tag_name().name() == "value")
+}
+
/// Merge `ops` into `text` in order. `None`: nothing changed (or nothing could be merged).
pub(super) fn merge_all(text: &str, within: Within, ops: &[Op], warnings: &mut Vec) -> Option {
let mut current: Option = None;
diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs
index 0f98a3f2b4..8674a4bd89 100644
--- a/crates/xfa/src/data/passwords.rs
+++ b/crates/xfa/src/data/passwords.rs
@@ -163,7 +163,7 @@ fn password_dataset_wants(doc: &Document) -> Vec {
.into_iter()
.filter_map(|(som, path, default)| {
let cur = data.value_at(&path)?;
- (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false })
+ (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false, picks: false })
})
.collect()
})
diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs
index d3568817d2..caa87cb3f0 100644
--- a/crates/xfa/src/data/path.rs
+++ b/crates/xfa/src/data/path.rs
@@ -131,6 +131,16 @@ fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Op
/// 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> {
+ match template_object(tpl, som)? {
+ Node::Field(f) => Some(f),
+ _ => None,
+ }
+}
+
+/// The template object (field, exclusion group, subform...) the object at a SOM path is an
+/// instance of, found as [`template_field`] finds a field. `None` for the root and for a button
+/// of an exclusion group.
+pub(crate) fn template_object<'a>(tpl: &'a Template, som: &str) -> Option<&'a Node> {
let path = som_to_path(som);
let ((root, _), rest) = path.split_first()?;
if tpl.root.common.name.as_deref() != Some(root.as_str()) {
@@ -145,10 +155,7 @@ pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Fie
_ => return None,
};
}
- match named_at(children, last, *last_index, 0)? {
- Node::Field(f) => Some(f),
- _ => None,
- }
+ named_at(children, last, *last_index, 0)
}
pub(super) fn path_som(path: &DataPath) -> String {
diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs
index 8042e774fe..8362960091 100644
--- a/crates/xfa/src/data/values.rs
+++ b/crates/xfa/src/data/values.rs
@@ -90,6 +90,9 @@ pub(super) struct Want {
pub(super) text: String,
/// Create the node even for an empty value (an instance of a repeating subform).
pub(super) ensure: bool,
+ /// A reset ([`DataOp::Reset`](super::DataOp::Reset)): a node holding a multi-select list's picks
+ /// takes the text as its picks.
+ pub(super) picks: bool,
}
/// `tpl`, or the document's own template when none is given (parsed once).
@@ -137,7 +140,7 @@ pub(super) fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Templat
FieldData::Radio(sel) => radio_dataset_value(doc, f, sel),
FieldData::None => continue,
};
- out.push(Want { som, path, text, ensure: false });
+ out.push(Want { som, path, text, ensure: false, picks: false });
}
out
}
diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs
index 4667036fe5..3dffa47b34 100644
--- a/crates/xfa/src/data/write.rs
+++ b/crates/xfa/src/data/write.rs
@@ -111,9 +111,10 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option)
};
for op in ops {
match op {
- DataOp::Value { path, text } if !path.is_empty() => {
+ DataOp::Value { path, text } | DataOp::Reset { path, text } if !path.is_empty() => {
// An empty value clears an existing node and needs no new one.
- run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false });
+ let picks = matches!(op, DataOp::Reset { .. });
+ run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false, picks });
}
DataOp::Instances { parent, name, count } => {
let done = queued.entry((parent, name.as_str())).or_insert(0);
@@ -129,7 +130,7 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option)
*done += 1;
let mut path = parent.clone();
path.push((name.clone(), k));
- run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true });
+ run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true, picks: false });
}
}
DataOp::Remove(path) if !path.is_empty() => {
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index f0906a603a..ab4e7af208 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -575,15 +575,30 @@ pub fn fields_by_som(doc: &Document) -> HashMap {
out
}
-/// What a reset gives the field at `som` in the data: its template default, or nothing when it has
-/// none. `None` for an object a reset restores no value of: not a field of the template, or a
-/// button, signature or picture field.
+/// What a reset gives the field or exclusion group at `som` in the data: its template default
+/// as saved (a choice list's item value, not its shown text, as [`Field::saved_value`] gives
+/// it), or nothing when it has none. A group's default is the on value of the first of its
+/// buttons whose own default is that on value. `None` for an object a reset restores no value
+/// of: not a field or group of the template, or a button, signature or picture field.
pub fn reset_default(tpl: &Template, som: &str) -> Option {
- let f = crate::data::template_field(tpl, som)?;
- if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) {
- return None;
+ match crate::data::template_object(tpl, som)? {
+ Node::Field(f) => {
+ if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) {
+ return None;
+ }
+ Some(f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default())
+ }
+ Node::ExclGroup(g) => Some(
+ g.fields
+ .iter()
+ .find_map(|f| {
+ let on = f.items.first().map(|s| s.trim()).filter(|s| !s.is_empty())?;
+ (f.value.plain()?.trim() == on).then(|| on.to_string())
+ })
+ .unwrap_or_default(),
+ ),
+ _ => None,
}
- Some(f.value.plain().unwrap_or_default())
}
/// Lay the form out again from its template, data and overrides, replacing the pages and the
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index e424be5441..1ef8239823 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -4145,3 +4145,76 @@ fn clear_password_values_clears_static_xfa_password_fields() {
let packets = read_packets(&doc).unwrap().unwrap();
assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp);
}
+
+/// A template for the reset defaults: a choice list showing `Label` and saving `code`, with
+/// `Label` its default, and an exclusion group whose second button is on by default.
+fn reset_defaults_template() -> Template {
+ let xml = r#"
+LabelOthercodeotherLabel
+ABB
+A
+
+"#;
+ crate::parse::parse(xml).unwrap().0
+}
+
+/// A reset gives a choice list the default it saves (the item value), not the text it shows,
+/// as the live form reads it.
+#[test]
+fn a_choice_list_resets_to_its_saved_default() {
+ let tpl = reset_defaults_template();
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].kind[0]").as_deref(), Some("code"));
+}
+
+/// An exclusion group resets to the on value of the button that is on by default, or to
+/// nothing; its buttons, and push buttons, restore no value of their own.
+#[test]
+fn an_exclusion_group_resets_to_its_default_button() {
+ let tpl = reset_defaults_template();
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0]").as_deref(), Some("B"));
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].none[0]").as_deref(), Some(""));
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0].b[0]"), None);
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].go[0]"), None);
+ assert_eq!(reset_default(&tpl, "form[0].page1[0]"), None);
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].missing[0]"), None);
+}
+
+/// A reset replaces a multi-select list's picks stored as `value` elements, in the packet and
+/// in the copy scripts read; an empty default leaves no pick. An ordinary value write still
+/// leaves a node with children alone.
+#[test]
+fn a_reset_replaces_structured_multi_select_picks() {
+ let xdp = template_with_data(1, "");
+ let path = som_to_path("form[0].tags[0]");
+ let value_at = |doc: &Document| data_of(doc).and_then(|d| d.value_at(&path)).unwrap_or_default();
+
+ let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
+ write_data_ops(&mut doc, &[DataOp::Value { path: path.clone(), text: String::new() }], None).unwrap();
+ assert_eq!(value_at(&doc), "a\nb", "an ordinary write leaves the picks");
+ let mut copy = data_of(&doc).unwrap();
+ copy.apply(&DataOp::Value { path: path.clone(), text: String::new() });
+ assert_eq!(copy.value_at(&path).as_deref(), Some("a\nb"));
+
+ let r = write_data_ops(&mut doc, &[DataOp::Reset { path: path.clone(), text: String::new() }], None).unwrap();
+ assert!(r.warnings.is_empty(), "{:?}", r.warnings);
+ assert_eq!(value_at(&doc), "", "an empty default leaves no pick");
+ copy.apply(&DataOp::Reset { path: path.clone(), text: String::new() });
+ assert_eq!(copy.value_at(&path).as_deref(), Some(""));
+
+ let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
+ write_data_ops(&mut doc, &[DataOp::Reset { path: path.clone(), text: "c\n\n d&e ".into() }], None).unwrap();
+ assert_eq!(value_at(&doc), "c\nd&e");
+ let packets = read_packets(&doc).unwrap().unwrap();
+ assert!(packets.xdp.contains("cd&e"), "{}", packets.xdp);
+ let mut copy = data_of(&Document::open(Arc::new(shell(&xdp))).unwrap()).unwrap();
+ copy.apply(&DataOp::Reset { path: path.clone(), text: "c\n\n d&e ".into() });
+ assert_eq!(copy.value_at(&path).as_deref(), Some("c\nd&e"));
+
+ // Other structured content (rich text) is not picks: a reset leaves it, with a warning.
+ let body = som_to_path("form[0].body[0]");
+ let r = write_data_ops(&mut doc, &[DataOp::Reset { path: body.clone(), text: String::new() }], None).unwrap();
+ assert!(!r.warnings.is_empty());
+ assert_eq!(data_of(&doc).unwrap().get(&body).map(|n| n.children.len()), Some(1));
+ copy.apply(&DataOp::Reset { path: body.clone(), text: String::new() });
+ assert_eq!(copy.get(&body).map(|n| n.children.len()), Some(1));
+}