M12: merge a run of XFA resets through a set, not a list scan

Resets in a row are applied together; the names were deduplicated by
scanning the kept list for each new one, quadratic in the names a script
passes (its effects may carry hundreds of thousands within the payload
cap). The run now keeps a set beside the ordered list, and a reset of the
whole form still takes in the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
This commit is contained in:
mtlcl410
2026-10-31 13:57:33 +08:00
co-authored by Claude Opus 5.5
parent d65c465b1b
commit 71a42a20d7
+70 -22
View File
@@ -5,7 +5,7 @@
//! presence and access changes become overrides kept in the PDF, added or removed rows change
//! the datasets, and any of those lays the form out again.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use pdfcraft_js::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode};
@@ -159,20 +159,38 @@ fn to_js(n: &FormNode) -> XfaNode {
}
}
/// Add the names of a `resetData` call to the run of resets before it. No names means the
/// whole form, which takes in every other.
fn merge_reset(merged: &mut Vec<String>, names: &[String]) {
if merged.is_empty() {
return;
}
if names.is_empty() {
merged.clear();
return;
}
for n in names {
if !merged.contains(n) {
merged.push(n.clone());
/// The names of a run of `resetData` calls, applied together: each name once, in the order
/// first given. A call without names resets the whole form, which takes in every other.
#[derive(Default)]
struct ResetRun {
names: Vec<String>,
/// `names` as a set, so adding a name costs the same however many there are (a script
/// may pass hundreds of thousands).
seen: HashSet<String>,
whole: bool,
}
impl ResetRun {
fn add(&mut self, names: &[String]) {
if self.whole {
return;
}
if names.is_empty() {
self.whole = true;
self.names.clear();
self.seen.clear();
return;
}
for n in names {
if self.seen.insert(n.clone()) {
self.names.push(n.clone());
}
}
}
/// The names to reset; none for the whole form.
fn names(&self) -> &[String] {
if self.whole { &[] } else { &self.names }
}
}
@@ -616,7 +634,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
}
// Resets in a row, applied together when the run of them ends.
let mut pending_reset: Option<Vec<String>> = None;
let mut pending_reset: Option<ResetRun> = None;
for (at, e) in effects.iter().enumerate() {
// Only a click script may print, save, open links or move the focus (not the
// calculations a click sets off, nor anything run on open or on a change).
@@ -669,14 +687,10 @@ impl<'a, 'b> Runner<'a, 'b> {
XfaEffect::ResetData(names) => {
// A script resetting several subforms in a row (`resetData` once for each)
// resets them together: the same fields end up at their defaults.
if let Some(merged) = pending_reset.as_mut() {
merge_reset(merged, names);
} else {
pending_reset = Some(names.clone());
}
pending_reset.get_or_insert_with(ResetRun::default).add(names);
let next_is_reset = effects.get(at.saturating_add(1)).is_some_and(|e| matches!(e, XfaEffect::ResetData(_)));
if !next_is_reset && let Some(names) = pending_reset.take() {
self.reset(&names)?;
if !next_is_reset && let Some(run) = pending_reset.take() {
self.reset(run.names())?;
}
}
XfaEffect::Print => self.run.out.requests.push(Request::Print),
@@ -1004,6 +1018,40 @@ pub(crate) fn mark_script_buttons(doc: &pdfcraft_cos::Document, fields: &mut [pd
mod tests {
use super::*;
/// A run of resets keeps each name once, in the order first given, and a reset of the whole
/// form takes in the rest, before or after it.
#[test]
fn resets_in_a_row_keep_each_name_once_and_a_whole_form_reset_wins() {
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let mut run = ResetRun::default();
run.add(&v(&["a", "b", "a"]));
run.add(&v(&["c", "b"]));
assert_eq!(run.names(), v(&["a", "b", "c"]).as_slice());
run.add(&[]);
assert!(run.names().is_empty());
run.add(&v(&["d"]));
assert!(run.names().is_empty(), "still the whole form");
let mut first = ResetRun::default();
first.add(&[]);
first.add(&v(&["a"]));
assert!(first.names().is_empty());
}
/// Adding a name to a run costs the same however many it holds: two runs of 60,000
/// distinct names merge at once (comparing each name with every kept one is 3.6 billion
/// comparisons).
#[test]
fn a_long_run_of_reset_names_merges_in_linear_time() {
let a: Vec<String> = (0..60_000).map(|i| format!("form1[0].a{i}")).collect();
let b: Vec<String> = (0..60_000).map(|i| format!("form1[0].b{i}")).collect();
let started = std::time::Instant::now();
let mut run = ResetRun::default();
run.add(&a);
run.add(&b);
assert_eq!(run.names().len(), 120_000);
assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
}
#[test]
fn script_names_match_indexed_som_paths() {
assert!(som_matches("form[0].page1[0].qty[0]", "qty"));