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#"
3
ackept
+"#, + ); + 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}>{}", 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#""#; + 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, "
ab

rich

"); + 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)); +}