fix(forms): read each JavaScript name tree node once (#2040)

document_scripts_named walked the catalog's /Names /JavaScript tree by
resolving and deep-cloning every kid onto its stack before looking at it.
The 10,000 cap counted nodes taken off the stack, not what went on it, so a
node whose /Kids lists itself k times pushed k full copies of itself (each
holding its k-entry /Kids array) per visit. A 6.5 KB file with k = 1000 made
`pdfcraft-cli run` (doc_open, js_document_scripts) exhaust an 8 GB address
space in 8 seconds and abort; the same walk runs when a field edit sets up
the script runner. A tree that lists a leaf twice returned its script twice.

Keep kids unresolved on the stack and skip references already read, as the
/Dests walker in organize does. The cap stays.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Leo Camus
2026-10-15 02:40:06 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 32a397f27a
commit 15ca7e13ac
2 changed files with 32 additions and 8 deletions
+19 -8
View File
@@ -6,7 +6,9 @@
//! calculation order, Format when the appearance is drawn — and applies what the script asks
//! for: the event's value, `rc`, and changes to other fields.
use pdfcraft_cos::{Document, Object, PdfString};
use std::collections::HashSet;
use pdfcraft_cos::{Document, ObjRef, Object, PdfString};
use crate::{Field, FieldKind, FieldValue, FormError, fields, flags};
@@ -172,18 +174,27 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
let Some(root) = doc.root() else { return Vec::new() };
let cat = doc.get(root);
let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() };
let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).map(|t| doc.resolve(t)) else { return Vec::new() };
let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() };
let mut out: Vec<(String, String)> = Vec::new();
let mut stack = vec![(*tree).clone()];
let mut seen = 0;
while let Some(node) = stack.pop() {
seen += 1;
if seen > 10_000 {
// Kids stay unresolved on the stack, and each node is read once: a node listed many times,
// or among its own kids, must not multiply the work.
let mut stack = vec![tree];
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut visited = 0;
while let Some(raw) = stack.pop() {
if let Object::Ref(r) = raw
&& !seen.insert(r)
{
continue;
}
visited += 1;
if visited > 10_000 {
break;
}
let node = doc.resolve(&raw);
let Some(d) = node.as_dict() else { continue };
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
stack.extend(kids.iter().map(|k| (*doc.resolve(k)).clone()));
stack.extend(kids);
}
if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) {
for pair in pairs.chunks(2) {
+13
View File
@@ -1997,3 +1997,16 @@ fn auto_text_size_fits_single_and_multiline_text() {
}
assert_eq!(auto_text_size(long, 50.0, 10.0, false, |_, _| f64::NAN), 10.0 / 1.15);
}
#[test]
fn document_scripts_read_each_name_tree_node_once() {
// The root lists itself and the leaf twice each.
let doc = document(&[
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(),
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
"<< /Kids [3 0 R 4 0 R 3 0 R 4 0 R] >>".into(),
"<< /Names [(init) 5 0 R] >>".into(),
"<< /S /JavaScript /JS (var ready = true;) >>".into(),
]);
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}