Merge PR #3318: M6: Optimize AcroForm JavaScript snapshots and diffs

parity/acrobat-features.toml: form calculation order entry keeps main's commands, tools and notes; evidence is the union of both sides.
This commit is contained in:
aiongg
2026-10-22 01:44:59 +00:00
6 changed files with 594 additions and 15 deletions
+86 -10
View File
@@ -70,11 +70,15 @@ fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 {
/// A form field as scripts see it.
pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState {
field_state_with_display(f, display(doc, f))
}
fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState {
let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone());
s.default = f.default.clone();
s.readonly = f.read_only();
s.required = f.has(pdfcraft_forms::flags::REQUIRED);
s.display = display(doc, f);
s.display = display;
s.char_limit = f.max_len;
s.options = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
@@ -85,10 +89,19 @@ pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::Fiel
/// The changes a script made, against the fields as they were.
fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]) -> Vec<FieldChange> {
if after.is_empty() {
return Vec::new();
}
// Outcomes contain only fields touched by the script. Index the original snapshot once,
// keeping the first match as the old linear lookup did for malformed duplicate names.
let mut by_name = std::collections::HashMap::with_capacity(before.len());
for field in before {
by_name.entry(field.name.as_str()).or_insert(field);
}
after
.iter()
.filter_map(|a| {
let b = before.iter().find(|b| b.name == a.name)?;
let b = by_name.get(a.name.as_str())?;
let c = FieldChange {
name: a.name.clone(),
value: (a.value != b.value).then(|| a.value.clone()),
@@ -106,14 +119,25 @@ pub struct JsRunner {
pub doc: pdfcraft_js::DocInfo,
pub doc_scripts: Vec<String>,
pub output: JsOutput,
/// The fields in the document as the forms code last read them (for `display`).
/// Immutable document snapshot from the start of the edit (for widget `display`).
cos: pdfcraft_cos::Document,
// `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only
// its scalar display readings, not COS objects: large forms exceed the object cache and
// otherwise reparse every widget for every calculation. Values/flags still come from
// the live `fields` snapshot on each call. A new edit gets a new runner and cache.
displays: std::collections::HashMap<pdfcraft_cos::ObjRef, i32>,
}
impl JsRunner {
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner {
let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) };
JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() }
JsRunner {
doc,
doc_scripts: pdfcraft_forms::document_scripts(cos),
output: JsOutput::default(),
cos: cos.clone(),
displays: std::collections::HashMap::new(),
}
}
}
@@ -130,19 +154,28 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
impl Scripts for JsRunner {
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
let states: Vec<_> = fields.iter().map(|f| field_state(&self.cos, f)).collect();
let states: Vec<_> = fields
.iter()
.map(|f| {
let display = match f.widgets.first() {
Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)),
None => pdfcraft_js::DISPLAY_VISIBLE,
};
field_state_with_display(f, display)
})
.collect();
let states = std::sync::Arc::new(states);
let ev = pdfcraft_js::Event::field(event.name(), &target.name, value);
let o = pdfcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default());
let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default());
self.output.absorb(&o);
if o.error.is_some() {
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None };
}
let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
ScriptResult {
rc: o.rc,
value: o.value.clone(),
changes: changes(&states, &after),
changes: changes(&states, &o.changed),
message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
}
}
@@ -213,8 +246,7 @@ impl Session {
None => pdfcraft_js::Event::doc("Console"),
};
let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default());
let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
let changes = changes(&states, &after);
let changes = changes(&states, &o.changed);
let resets: Vec<Vec<String>> = o
.requests
.iter()
@@ -368,3 +400,47 @@ impl Session {
Ok(names)
}
}
#[cfg(test)]
mod review_tests {
use super::*;
#[test]
fn duplicate_field_names_diff_against_first_original() {
let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]);
let mut second = first.clone();
second.value = vec!["second".into()];
second.display = pdfcraft_js::DISPLAY_HIDDEN;
let before = vec![first.clone(), second.clone()];
assert!(changes(&before, std::slice::from_ref(&first)).is_empty());
let actual = changes(&before, std::slice::from_ref(&second));
assert_eq!(
actual,
vec![FieldChange {
name: "duplicate".into(),
value: Some(second.value),
display: Some(pdfcraft_js::DISPLAY_HIDDEN),
..Default::default()
}]
);
let outcome = pdfcraft_js::run_shared(
"event.value = getField('duplicate').value; getField('duplicate').value = 'updated';",
&pdfcraft_js::Event::field("Calculate", "duplicate", ""),
&pdfcraft_js::DocInfo::default(),
std::sync::Arc::new(before.clone()),
&[],
Limits::default(),
);
assert_eq!(outcome.error, None);
assert_eq!(outcome.value, "first");
assert_eq!(outcome.changed.len(), 1);
assert_eq!(
changes(&before, &outcome.changed),
vec![FieldChange { name: "duplicate".into(), value: Some(vec!["updated".into()]), ..Default::default() }]
);
assert!(changes(&before, &[]).is_empty());
let mut missing = first;
missing.name = "missing".into();
assert!(changes(&before, &[missing]).is_empty());
}
}
+182
View File
@@ -6147,3 +6147,185 @@ fn undo_never_makes_an_unsaved_document_clean() {
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
/// Reproducible workload with 3,129 fields and 190 ordered custom calculations.
/// No external PDF assets are needed; only in-memory contributor-original objects.
fn synthetic_acroform(field_count: usize, calculation_count: usize) -> Vec<u8> {
use pdfcraft_cos::{Dict, Object, PdfString};
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(6))).unwrap();
let mut refs = Vec::new();
let mut order = Vec::new();
for i in 0..field_count {
let name = match i {
0 => "ranks".to_string(),
1 => "trained".to_string(),
2 => "bonus".to_string(),
_ => format!("field{i}"),
};
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name("Widget"));
d.set(b"FT".to_vec(), Object::name(if i == 1 { "Btn" } else { "Tx" }));
d.set(b"T".to_vec(), PdfString::text(&name));
d.set(b"V".to_vec(), if i == 1 { Object::name("Off") } else { Object::String(PdfString::text("0")) });
d.set(b"Rect".to_vec(), Object::Array([20, 20, 100, 40].map(Object::Int).to_vec()));
d.set(b"P".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)));
if (2..calculation_count + 2).contains(&i) {
let js = if i == 2 {
"var r = Number(getField('ranks').value); event.value = r + (r > 0 && getField('trained').isBoxChecked(0) ? 3 : 0);".to_string()
} else {
format!("event.value = Number(getField('{}').value) + 1;", if i == 3 { "bonus".to_string() } else { format!("field{}", i - 1) })
};
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::name("JavaScript"));
a.set(b"JS".to_vec(), PdfString::text(&js));
let mut aa = Dict::new();
aa.set(b"C".to_vec(), Object::Dict(a));
d.set(b"AA".to_vec(), Object::Dict(aa));
}
let r = doc.add(Object::Dict(d));
refs.push(Object::Ref(r));
if (2..calculation_count + 2).contains(&i) {
order.push(Object::Ref(r));
}
}
doc.update_dict(pdfcraft_cos::ObjRef::new(4, 0), |d| d.set(b"Annots".to_vec(), Object::Array(refs.clone()))).unwrap();
let mut af = Dict::new();
af.set(b"Fields".to_vec(), Object::Array(refs));
af.set(b"CO".to_vec(), Object::Array(order));
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(af))).unwrap();
pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap()
}
#[test]
#[ignore = "manual performance measurement: 3,129 fields, 190 scripts"]
fn acroform_interaction_benchmark() {
let bytes = Arc::new(synthetic_acroform(3129, 190));
// Optional local export for comparing CLI build profiles. Never overwrite an input.
if let Some(path) = std::env::var_os("PDFCRAFT_BENCH_EXPORT") {
use std::io::Write;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).unwrap();
file.write_all(&bytes).unwrap();
}
let mut s = Session::new();
let id = s.open("synthetic-large-form.pdf", None, bytes, None).unwrap();
let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone();
for (name, v) in [
("ranks", FieldValue::Text("1".into())),
("trained", FieldValue::Check(true)),
("ranks", FieldValue::Text("2".into())),
("trained", FieldValue::Check(false)),
("ranks", FieldValue::Text("0".into())),
] {
let started = std::time::Instant::now();
s.apply(id, Edit::SetFieldValue { name: name.into(), value: v }).unwrap();
eprintln!("BENCH {name}: {:.3} ms", started.elapsed().as_secs_f64() * 1000.0);
assert!(s.take_js_output(id).errors.is_empty());
}
assert_eq!(value(&s, "bonus"), ["0"]);
assert_eq!(value(&s, "field191"), ["189"]);
assert_eq!(value(&s, "field3128"), ["0"]);
}
#[test]
fn checkbox_and_rank_changes_recalculate_ordered_dependents() {
let mut s = Session::new();
let id = s.open("skills.pdf", None, Arc::new(synthetic_acroform(8, 4)), None).unwrap();
for (name, input, bonus) in [
("trained", FieldValue::Check(true), "0"),
("ranks", FieldValue::Text("1".into()), "4"),
("trained", FieldValue::Check(false), "1"),
("ranks", FieldValue::Text("2".into()), "2"),
("trained", FieldValue::Check(true), "5"),
("ranks", FieldValue::Text("0".into()), "0"),
] {
s.apply(id, Edit::SetFieldValue { name: name.into(), value: input }).unwrap();
let fields = &s.get(id).unwrap().form;
let value = |name: &str| fields.iter().find(|f| f.name == name).unwrap().value.clone();
assert_eq!(value("bonus"), [bonus]);
assert_eq!(value("field5"), [(bonus.parse::<u32>().unwrap() + 3).to_string()]);
assert_eq!(value("field7"), ["0"]);
assert!(s.take_js_output(id).errors.is_empty());
}
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]);
let saved = s.save_bytes(id).unwrap();
let reopened = s.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]);
let appearance = |s: &Session| {
let doc = s.get(id).unwrap();
let widget = doc.form.iter().find(|f| f.name == "bonus").unwrap().widgets.first().unwrap().obj;
doc.editor.as_ref().unwrap().cos.get(widget).as_dict().unwrap().get(b"AP").cloned()
};
let before = appearance(&s);
assert!(before.is_some());
s.apply(id, Edit::SetFieldValue { name: "field7".into(), value: FieldValue::Text("9".into()) }).unwrap();
assert_eq!(appearance(&s), before, "unchanged calculated values keep their appearance streams");
}
#[test]
fn dynamic_calculation_reads_and_side_effects_reach_later_fields() {
use pdfcraft_cos::{Dict, Object, PdfString};
let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(8, 4))).unwrap();
let fields = pdfcraft_forms::fields(&cos);
let bonus = fields.iter().find(|f| f.name == "bonus").unwrap();
let selector = fields.iter().find(|f| f.name == "field7").unwrap();
cos.update_dict(selector.obj, |d| d.set(b"V".to_vec(), PdfString::text("ranks"))).unwrap();
let mut a = Dict::new();
a.set(b"S".to_vec(), Object::name("JavaScript"));
a.set(b"JS".to_vec(), PdfString::text(
"var key = getField('field7').value; var v = Number(getField(key).value); getField('field6').value = v * 2; getField('field6').readonly = true; event.value = v + 1;"
));
let mut aa = Dict::new();
aa.set(b"C".to_vec(), Object::Dict(a));
cos.update_dict(bonus.obj, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap();
let bytes = pdfcraft_cos::write_incremental(&cos, &Default::default()).unwrap();
let mut s = Session::new();
let id = s.open("dynamic.pdf", None, Arc::new(bytes), None).unwrap();
s.apply(id, Edit::SetFieldValue { name: "ranks".into(), value: FieldValue::Text("4".into()) }).unwrap();
let fields = &s.get(id).unwrap().form;
let field = |name: &str| fields.iter().find(|f| f.name == name).unwrap();
assert_eq!(field("bonus").value, ["5"]);
assert_eq!(field("field5").value, ["8"], "later calculations see the ordered result");
assert_eq!(field("field6").value, ["8"], "side-effect value applied");
assert!(field("field6").read_only(), "side-effect property applied");
assert_eq!(field("field7").value, ["ranks"]);
assert!(s.take_js_output(id).errors.is_empty());
}
#[test]
fn scripted_display_changes_survive_warm_cache_and_new_edit() {
use pdfcraft_forms::{FieldEvent, Scripts};
let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(2, 0))).unwrap();
for (display, flags) in [(1, 6), (3, 36), (2, 0), (0, 4)] {
let fields = pdfcraft_forms::fields(&cos);
let target = fields.iter().find(|f| f.name == "ranks").unwrap();
let other = fields.iter().find(|f| f.name == "trained").unwrap();
let old_display = crate::js::field_state(&cos, other).display;
let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf");
// Populate the display cache before a script writes the same field.
let warm = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields);
assert_eq!(warm.value, old_display.to_string());
let result = runner.run(
FieldEvent::Calculate,
&format!("getField('trained').display = {display}; event.value = getField('trained').display;"),
target,
"",
&fields,
);
assert_eq!(result.value, display.to_string());
assert_eq!(result.changes.len(), 1);
assert_eq!(result.changes.first().unwrap().display, Some(display));
pdfcraft_forms::apply_script_changes(&mut cos, &result.changes, &mut pdfcraft_forms::NoScripts).unwrap();
assert_eq!(cos.get(other.widgets.first().unwrap().obj).as_dict().unwrap().int(b"F").unwrap() & 38, flags);
// Within an edit the original implementation reads an immutable pre-edit COS snapshot.
let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields);
assert_eq!(same_edit.value, old_display.to_string());
// The next edit must rebuild its cache from the newly written annotation flags.
let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf");
let read = next.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &pdfcraft_forms::fields(&cos));
assert_eq!(read.value, display.to_string());
assert!(read.changes.is_empty());
assert!(runner.output.errors.is_empty());
assert!(next.output.errors.is_empty());
}
}