From 2ae099d20c508329ab26af4a299825d9f0ed5fc8 Mon Sep 17 00:00:00 2001 From: lowtide-lab <54317404+lowtide-lab@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:12:43 +0100 Subject: [PATCH 1/5] fix(xfa): AcroForm field walks read each field once and bound what they queue --- crates/xfa/src/pdf.rs | 75 ++++++++++++++++++++++++++++++++++------ crates/xfa/src/script.rs | 29 +++++++++++----- crates/xfa/src/tests.rs | 27 +++++++++++++++ 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 0c21507abf..60f4835ee8 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -90,7 +90,7 @@ struct Signed { appearance: Option, } -/// Most fields looked at for signatures. +/// Most fields looked at for signatures, counted as they are queued. const MAX_FIELDS_SEARCHED: usize = 20_000; /// Deepest field nesting followed. const MAX_FIELD_DEPTH: usize = 32; @@ -105,21 +105,29 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, let mut names = HashSet::new(); let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) }; let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) }; + // Counted as queued, not as visited: a field listing one kid many times (or itself) would + // otherwise queue them all on every visit, far past the limit, before the count stopped it. let mut stack: Vec<(Object, String, bool, usize)> = - fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect(); - let mut seen = 0usize; + fields.as_array().into_iter().flatten().take(MAX_FIELDS_SEARCHED).map(|f| (f.clone(), String::new(), false, 0)).collect(); + let mut queued = stack.len(); + // And each field is read once, however many times it is listed. + let mut seen = HashSet::new(); while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() { - seen += 1; - if seen > MAX_FIELDS_SEARCHED { - break; + if let Object::Ref(r) = field + && !seen.insert(r) + { + continue; } let field = doc.resolve(&field); let Some(d) = field.as_dict() else { continue }; if d.contains(NODE_KEY) { // Generated to name a signature field: only what it holds is looked at. if depth < MAX_FIELD_DEPTH { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.into_iter().map(|k| (k, String::new(), false, depth + 1))); + let kids = d.get(b"Kids").map(|k| doc.resolve(k)); + let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(); + let room = MAX_FIELDS_SEARCHED.saturating_sub(queued); + queued += kids.len().min(room); + stack.extend(kids.iter().take(room).map(|k| (k.clone(), String::new(), false, depth + 1))); } continue; } @@ -145,14 +153,17 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, Some(ft) => ft == b"Sig", None => parent_is_sig, }; - let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + let kids = d.get(b"Kids").map(|k| doc.resolve(k)); + let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(); if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) { // The appearance is on the field when it is its own widget, or on its one widget. let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); } if depth < MAX_FIELD_DEPTH { - stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1))); + let room = MAX_FIELDS_SEARCHED.saturating_sub(queued); + queued += kids.len().min(room); + stack.extend(kids.iter().take(room).map(|k| (k.clone(), name.clone(), is_sig, depth + 1))); } } (out, names) @@ -961,7 +972,49 @@ pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { #[cfg(test)] mod tests { - use super::parse_hex32; + use pdfcraft_cos::{Dict, Document, Object, PdfString}; + + use super::{MAX_FIELDS_SEARCHED, existing_fields, parse_hex32}; + + /// The fields `existing_fields` finds under an AcroForm listing `fields`. + fn names_found(doc: &Document, fields: Vec) -> Vec { + let mut acro = Dict::new(); + acro.set(b"Fields".to_vec(), Object::Array(fields)); + let mut catalog = Dict::new(); + catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)); + let (signed, names) = existing_fields(doc, &catalog); + assert!(signed.is_empty()); + let mut names: Vec = names.into_iter().collect(); + names.sort(); + names + } + + fn field(name: &str, kids: Object) -> Object { + let mut d = Dict::new(); + d.set(b"T".to_vec(), Object::String(PdfString::literal(name))); + d.set(b"Kids".to_vec(), kids); + Object::Dict(d) + } + + #[test] + fn fields_listed_many_times_queue_a_bounded_number_of_kids() { + // A field lists itself 5,000 times: queuing every kid on each of 20,000 visits would + // hold a hundred million entries. It is read once. + let mut doc = Document::new_empty(); + let r = doc.add(Object::Null); + doc.set(r, field("a", Object::Array(vec![Object::Ref(r); 5_000]))); + assert_eq!(names_found(&doc, vec![Object::Ref(r)]), ["a"]); + + // 5,000 fields share one /Kids array listing each of them 200 times. Read once each, + // the fields down one branch still queue the whole array at every level: 32 million + // entries, unless the queue itself is bounded. + let mut doc = Document::new_empty(); + let kids = doc.add(Object::Null); + let fields: Vec = (0..5_000).map(|i| Object::Ref(doc.add(field(&format!("f{i}"), Object::Ref(kids))))).collect(); + doc.set(kids, Object::Array(fields.iter().cycle().take(1_000_000).cloned().collect())); + let names = names_found(&doc, fields[..1].to_vec()); + assert!(names.contains(&"f0".to_string()) && names.len() <= MAX_FIELDS_SEARCHED, "{} names", names.len()); + } #[test] fn parse_hex32_rejects_non_hex_digits() { diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 6946aa9b1c..84d3c1888b 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -2,7 +2,7 @@ //! events a template declares, the presence overrides scripts make (kept in the PDF so they //! survive undo and reopening), and laying the form out again after scripts changed it. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, Object, PdfString}; @@ -15,6 +15,8 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides"; const MAX_DEPTH: usize = 64; const MAX_EVENTS: usize = 20_000; +/// Most `/Kids` entries [`fields_by_som`] follows in all, fields already read included. +const MAX_FIELD_STEPS: usize = 1 << 20; /// Most bytes of script the events hold together: each instance of a repeated subform holds its /// own copy, so a long script repeated many times stops here (as much as the XFA packets hold). pub(crate) const MAX_EVENT_SCRIPT_BYTES: usize = 64 << 20; @@ -543,8 +545,17 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template { /// The generated fields of the AcroForm by SOM path: `som → (field name, is a radio group)`. pub fn fields_by_som(doc: &Document) -> HashMap { - fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, out: &mut HashMap, depth: usize) { - if depth > MAX_DEPTH || out.len() > 100_000 { + // Each field is read once: a field listed twice in `/Kids` (or among its own kids) would + // otherwise be walked again for every path to it, doubling the work at each level. `steps` + // counts every entry followed, so fields sharing one long `/Kids` array stay bounded too. + struct Walk { + out: HashMap, + seen: HashSet, + steps: usize, + } + fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) { + w.steps += 1; + if depth > MAX_DEPTH || w.out.len() > 100_000 || w.steps > MAX_FIELD_STEPS || !w.seen.insert(r) { return; } let o = doc.get(r); @@ -558,21 +569,21 @@ pub fn fields_by_som(doc: &Document) -> HashMap { if let Some(som) = d.get(crate::pdf::SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text())) && t.is_some() { - out.insert(som, name.clone()); + w.out.insert(som, name.clone()); } if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { for k in kids.iter().filter_map(Object::as_ref) { - walk(doc, k, &name, out, depth + 1); + walk(doc, k, &name, w, depth + 1); } } } - let mut out = HashMap::new(); - let Some((_, acro)) = acroform(doc) else { return out }; + let Some((_, acro)) = acroform(doc) else { return HashMap::new() }; let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut w = Walk { out: HashMap::new(), seen: HashSet::new(), steps: 0 }; for f in fields.iter().filter_map(Object::as_ref) { - walk(doc, f, "", &mut out, 0); + walk(doc, f, "", &mut w, 0); } - out + w.out } /// 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 ca129abceb..b2a6a19729 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4089,3 +4089,30 @@ fn clear_password_values_clears_static_xfa_password_fields() { let packets = read_packets(&doc).unwrap().unwrap(); assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } + +#[test] +fn fields_by_som_reads_a_field_listed_many_times_once() { + // A field lists itself twice and its one widget twice: walked once per path to it, that is + // 2^64 visits at the depth limit. + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t)); + let mut doc = Document::new_empty(); + let mut widget = Dict::new(); + widget.set(b"T".to_vec(), s("name")); + widget.set(crate::pdf::SOM_KEY.to_vec(), s("form1[0].name[0]")); + let widget = Object::Ref(doc.add(Object::Dict(widget))); + let parent = doc.add(Object::Null); + let mut d = Dict::new(); + d.set(b"T".to_vec(), s("form1")); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(parent), widget.clone(), Object::Ref(parent), widget])); + doc.set(parent, Object::Dict(d)); + let mut acro = Dict::new(); + acro.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)])); + let mut cat = Dict::new(); + cat.set(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())); + cat.set(b"AcroForm".to_vec(), Object::Dict(acro)); + let cat = doc.add(Object::Dict(cat)); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat)); + let by_som = fields_by_som(&doc); + assert_eq!(by_som.len(), 1, "{by_som:?}"); + assert_eq!(by_som.get("form1[0].name[0]").map(String::as_str), Some("form1.name")); +} From 656958ad7754d009509e8a9ba4a0b2db526853c9 Mon Sep 17 00:00:00 2001 From: lowtide-lab <54317404+lowtide-lab@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:46:46 +0100 Subject: [PATCH 2/5] fix(xfa): queue each field when first listed, and stop every loop once the walk is spent --- crates/xfa/src/pdf.rs | 80 +++++++++++++++++++++++++++++----------- crates/xfa/src/script.rs | 28 +++++++------- 2 files changed, 73 insertions(+), 35 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 60f4835ee8..9117bcc37c 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -94,6 +94,8 @@ struct Signed { const MAX_FIELDS_SEARCHED: usize = 20_000; /// Deepest field nesting followed. const MAX_FIELD_DEPTH: usize = 32; +/// Most `/Fields` and `/Kids` entries read while looking, repeats included. +const MAX_KIDS_READ: usize = 1 << 20; /// The fields the document came with: the full name of each, and the signed signature fields by /// SOM path. Signing applications name a dynamic form's fields by their SOM path @@ -105,29 +107,16 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, let mut names = HashSet::new(); let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) }; let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) }; - // Counted as queued, not as visited: a field listing one kid many times (or itself) would - // otherwise queue them all on every visit, far past the limit, before the count stopped it. - let mut stack: Vec<(Object, String, bool, usize)> = - fields.as_array().into_iter().flatten().take(MAX_FIELDS_SEARCHED).map(|f| (f.clone(), String::new(), false, 0)).collect(); - let mut queued = stack.len(); - // And each field is read once, however many times it is listed. - let mut seen = HashSet::new(); - while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() { - if let Object::Ref(r) = field - && !seen.insert(r) - { - continue; - } - let field = doc.resolve(&field); + let mut walk = FieldQueue::default(); + walk.queue(fields.as_array().map(Vec::as_slice).unwrap_or_default(), "", false, 0); + while let Some((field, prefix, parent_is_sig, depth)) = walk.stack.pop() { + let field = doc.get(field); let Some(d) = field.as_dict() else { continue }; if d.contains(NODE_KEY) { // Generated to name a signature field: only what it holds is looked at. if depth < MAX_FIELD_DEPTH { let kids = d.get(b"Kids").map(|k| doc.resolve(k)); - let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(); - let room = MAX_FIELDS_SEARCHED.saturating_sub(queued); - queued += kids.len().min(room); - stack.extend(kids.iter().take(room).map(|k| (k.clone(), String::new(), false, depth + 1))); + walk.queue(kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), "", false, depth + 1); } continue; } @@ -161,14 +150,42 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); } if depth < MAX_FIELD_DEPTH { - let room = MAX_FIELDS_SEARCHED.saturating_sub(queued); - queued += kids.len().min(room); - stack.extend(kids.iter().take(room).map(|k| (k.clone(), name.clone(), is_sig, depth + 1))); + walk.queue(kids, &name, is_sig, depth + 1); } } (out, names) } +/// The fields [`existing_fields`] still has to look at: each with its parent's full name, +/// whether it inherits a signature type, and its depth. +#[derive(Default)] +struct FieldQueue { + stack: Vec<(ObjRef, String, bool, usize)>, + seen: HashSet, + read: usize, +} + +impl FieldQueue { + /// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are + /// indirect references (ISO 32000-2 §12.7.3.1); anything else is skipped. A field listed + /// again, by this list or another, takes no room, and at most [`MAX_FIELDS_SEARCHED`] are + /// ever queued: a field listing one kid many times, or fields sharing one long `/Kids`, would + /// otherwise queue it all on every visit. Reading the repeats costs too, so at most + /// [`MAX_KIDS_READ`] entries are read in all. + fn queue(&mut self, kids: &[Object], prefix: &str, is_sig: bool, depth: usize) { + for k in kids { + self.read += 1; + if self.seen.len() >= MAX_FIELDS_SEARCHED || self.read > MAX_KIDS_READ { + return; + } + let Some(r) = k.as_ref() else { continue }; + if self.seen.insert(r) { + self.stack.push((r, prefix.to_string(), is_sig, depth)); + } + } + } +} + impl Emitter<'_> { fn da(face: &Face) -> Vec { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() @@ -1016,6 +1033,27 @@ mod tests { assert!(names.contains(&"f0".to_string()) && names.len() <= MAX_FIELDS_SEARCHED, "{} names", names.len()); } + #[test] + fn a_field_listed_many_times_leaves_room_for_the_fields_under_it() { + // The AcroForm lists one field 19,999 times. The copies take no room, so its kids are + // still queued, and the signed one among them is found. + let mut doc = Document::new_empty(); + let plain = doc.add(field("plain", Object::Array(Vec::new()))); + let mut sig = Dict::new(); + sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig"))); + sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec())); + sig.set(b"V".to_vec(), Object::Dict(Dict::new())); + let sig = doc.add(Object::Dict(sig)); + let parent = doc.add(field("c", Object::Array(vec![Object::Ref(plain), Object::Ref(sig)]))); + let mut acro = Dict::new(); + acro.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent); MAX_FIELDS_SEARCHED - 1])); + let mut catalog = Dict::new(); + catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)); + let (signed, names) = existing_fields(&doc, &catalog); + assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::>()); + assert!(names.contains("c.plain")); + } + #[test] fn parse_hex32_rejects_non_hex_digits() { for prefix in ["+f", "-f", " f"] { diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 84d3c1888b..3e70849bee 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -547,19 +547,23 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template { pub fn fields_by_som(doc: &Document) -> HashMap { // Each field is read once: a field listed twice in `/Kids` (or among its own kids) would // otherwise be walked again for every path to it, doubling the work at each level. `steps` - // counts every entry followed, so fields sharing one long `/Kids` array stay bounded too. + // counts every entry followed, so fields sharing one long `/Kids` array stay bounded too; + // `walk` returns false once it is spent, and every loop then stops. struct Walk { out: HashMap, seen: HashSet, steps: usize, } - fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) { + fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) -> bool { w.steps += 1; - if depth > MAX_DEPTH || w.out.len() > 100_000 || w.steps > MAX_FIELD_STEPS || !w.seen.insert(r) { - return; + if w.steps > MAX_FIELD_STEPS || w.out.len() > 100_000 { + return false; + } + if depth > MAX_DEPTH || !w.seen.insert(r) { + return true; } let o = doc.get(r); - let Some(d) = o.as_dict() else { return }; + let Some(d) = o.as_dict() else { return true }; let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text())); let name = match (&t, prefix.is_empty()) { (Some(t), true) => t.clone(), @@ -571,18 +575,14 @@ pub fn fields_by_som(doc: &Document) -> HashMap { { w.out.insert(som, name.clone()); } - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - for k in kids.iter().filter_map(Object::as_ref) { - walk(doc, k, &name, w, depth + 1); - } - } + // Read in place: a copy of the array at every level would cost its length each time. + let kids = d.get(b"Kids").map(|k| doc.resolve(k)); + kids.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_ref).all(|k| walk(doc, k, &name, w, depth + 1)) } let Some((_, acro)) = acroform(doc) else { return HashMap::new() }; - let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let fields = acro.get(b"Fields").map(|f| doc.resolve(f)); let mut w = Walk { out: HashMap::new(), seen: HashSet::new(), steps: 0 }; - for f in fields.iter().filter_map(Object::as_ref) { - walk(doc, f, "", &mut w, 0); - } + let _ = fields.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_ref).all(|f| walk(doc, f, "", &mut w, 0)); w.out } From a86ff56873ab79748fd9e3ac5229e3d67cb6b301 Mon Sep 17 00:00:00 2001 From: lowtide-lab <54317404+lowtide-lab@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:06:40 +0100 Subject: [PATCH 3/5] fix(xfa): tell fields apart by object number, and count every entry the walk looks at --- crates/xfa/src/pdf.rs | 59 ++++++++++++++++++++++---------- crates/xfa/src/script.rs | 32 ++++++++++------- crates/xfa/src/tests.rs | 74 +++++++++++++++++++++++++++++++++------- 3 files changed, 123 insertions(+), 42 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 9117bcc37c..6be47b7dba 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -161,17 +161,19 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, #[derive(Default)] struct FieldQueue { stack: Vec<(ObjRef, String, bool, usize)>, - seen: HashSet, + /// Object numbers queued: [`Document::get`] finds a field by number alone, so `5 0 R` and + /// `5 1 R` are the same field. + seen: HashSet, read: usize, } impl FieldQueue { /// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are /// indirect references (ISO 32000-2 §12.7.3.1); anything else is skipped. A field listed - /// again, by this list or another, takes no room, and at most [`MAX_FIELDS_SEARCHED`] are - /// ever queued: a field listing one kid many times, or fields sharing one long `/Kids`, would - /// otherwise queue it all on every visit. Reading the repeats costs too, so at most - /// [`MAX_KIDS_READ`] entries are read in all. + /// again, by this list or another, under any generation, takes no room, and at most + /// [`MAX_FIELDS_SEARCHED`] are ever queued: a field listing one kid many times, or fields + /// sharing one long `/Kids`, would otherwise queue it all on every visit. Reading the repeats + /// costs too, so at most [`MAX_KIDS_READ`] entries are read in all. fn queue(&mut self, kids: &[Object], prefix: &str, is_sig: bool, depth: usize) { for k in kids { self.read += 1; @@ -179,7 +181,7 @@ impl FieldQueue { return; } let Some(r) = k.as_ref() else { continue }; - if self.seen.insert(r) { + if self.seen.insert(r.num) { self.stack.push((r, prefix.to_string(), is_sig, depth)); } } @@ -989,17 +991,15 @@ pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { #[cfg(test)] mod tests { - use pdfcraft_cos::{Dict, Document, Object, PdfString}; + use std::collections::{HashMap, HashSet}; - use super::{MAX_FIELDS_SEARCHED, existing_fields, parse_hex32}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; + + use super::{MAX_FIELDS_SEARCHED, Signed, existing_fields, parse_hex32}; /// The fields `existing_fields` finds under an AcroForm listing `fields`. fn names_found(doc: &Document, fields: Vec) -> Vec { - let mut acro = Dict::new(); - acro.set(b"Fields".to_vec(), Object::Array(fields)); - let mut catalog = Dict::new(); - catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)); - let (signed, names) = existing_fields(doc, &catalog); + let (signed, names) = existing(doc, fields); assert!(signed.is_empty()); let mut names: Vec = names.into_iter().collect(); names.sort(); @@ -1038,20 +1038,43 @@ mod tests { // The AcroForm lists one field 19,999 times. The copies take no room, so its kids are // still queued, and the signed one among them is found. let mut doc = Document::new_empty(); + let parent = signed_parent(&mut doc); + let (signed, names) = existing(&doc, vec![Object::Ref(parent); MAX_FIELDS_SEARCHED - 1]); + assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::>()); + assert!(names.contains("c.plain")); + } + + #[test] + fn a_field_listed_under_many_generations_is_queued_once() { + // `Document::get` finds a field by object number alone, so `5 0 R` and `5 1 R` are one + // field. Listed 19,999 times, each under another generation, it is still queued once, + // and the signed field under it is found. + let mut doc = Document::new_empty(); + let parent = signed_parent(&mut doc); + let fields = (0..MAX_FIELDS_SEARCHED - 1).map(|g| Object::Ref(ObjRef::new(parent.num, u16::try_from(g).unwrap()))).collect(); + let (signed, names) = existing(&doc, fields); + assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::>()); + assert!(names.contains("c.plain")); + } + + /// Field `c` with two kids: `plain`, and `sig`, a signed signature field. + fn signed_parent(doc: &mut Document) -> ObjRef { let plain = doc.add(field("plain", Object::Array(Vec::new()))); let mut sig = Dict::new(); sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig"))); sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec())); sig.set(b"V".to_vec(), Object::Dict(Dict::new())); let sig = doc.add(Object::Dict(sig)); - let parent = doc.add(field("c", Object::Array(vec![Object::Ref(plain), Object::Ref(sig)]))); + doc.add(field("c", Object::Array(vec![Object::Ref(plain), Object::Ref(sig)]))) + } + + /// What `existing_fields` finds under an AcroForm listing `fields`. + fn existing(doc: &Document, fields: Vec) -> (HashMap, HashSet) { let mut acro = Dict::new(); - acro.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent); MAX_FIELDS_SEARCHED - 1])); + acro.set(b"Fields".to_vec(), Object::Array(fields)); let mut catalog = Dict::new(); catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)); - let (signed, names) = existing_fields(&doc, &catalog); - assert!(signed.contains_key("c.sig"), "{:?}", signed.keys().collect::>()); - assert!(names.contains("c.plain")); + existing_fields(doc, &catalog) } #[test] diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index 3e70849bee..b9ee8cdcb8 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -15,7 +15,8 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides"; const MAX_DEPTH: usize = 64; const MAX_EVENTS: usize = 20_000; -/// Most `/Kids` entries [`fields_by_som`] follows in all, fields already read included. +/// Most `/Fields` and `/Kids` entries [`fields_by_som`] looks at in all: fields already read, and +/// entries that are not references, included. const MAX_FIELD_STEPS: usize = 1 << 20; /// Most bytes of script the events hold together: each instance of a repeated subform holds its /// own copy, so a long script repeated many times stops here (as much as the XFA packets hold). @@ -546,20 +547,27 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template { /// The generated fields of the AcroForm by SOM path: `som → (field name, is a radio group)`. pub fn fields_by_som(doc: &Document) -> HashMap { // Each field is read once: a field listed twice in `/Kids` (or among its own kids) would - // otherwise be walked again for every path to it, doubling the work at each level. `steps` - // counts every entry followed, so fields sharing one long `/Kids` array stay bounded too; - // `walk` returns false once it is spent, and every loop then stops. + // otherwise be walked again for every path to it, doubling the work at each level. Fields + // are told apart by object number, as `Document::get` finds them, so `5 0 R` and `5 1 R` + // are one. `steps` counts every entry looked at, references or not, so fields sharing one + // long `/Kids` array stay bounded too; `walk_all` returns false once it is spent, and every + // loop then stops. struct Walk { out: HashMap, - seen: HashSet, + seen: HashSet, steps: usize, } + fn walk_all(doc: &Document, kids: &[Object], prefix: &str, w: &mut Walk, depth: usize) -> bool { + kids.iter().all(|k| { + w.steps += 1; + if w.steps > MAX_FIELD_STEPS || w.out.len() > 100_000 { + return false; + } + k.as_ref().is_none_or(|r| walk(doc, r, prefix, w, depth)) + }) + } fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) -> bool { - w.steps += 1; - if w.steps > MAX_FIELD_STEPS || w.out.len() > 100_000 { - return false; - } - if depth > MAX_DEPTH || !w.seen.insert(r) { + if depth > MAX_DEPTH || !w.seen.insert(r.num) { return true; } let o = doc.get(r); @@ -577,12 +585,12 @@ pub fn fields_by_som(doc: &Document) -> HashMap { } // Read in place: a copy of the array at every level would cost its length each time. let kids = d.get(b"Kids").map(|k| doc.resolve(k)); - kids.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_ref).all(|k| walk(doc, k, &name, w, depth + 1)) + walk_all(doc, kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), &name, w, depth + 1) } let Some((_, acro)) = acroform(doc) else { return HashMap::new() }; let fields = acro.get(b"Fields").map(|f| doc.resolve(f)); let mut w = Walk { out: HashMap::new(), seen: HashSet::new(), steps: 0 }; - let _ = fields.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_ref).all(|f| walk(doc, f, "", &mut w, 0)); + walk_all(doc, fields.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), "", &mut w, 0); w.out } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index b2a6a19729..552484fb18 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4094,25 +4094,75 @@ fn clear_password_values_clears_static_xfa_password_fields() { fn fields_by_som_reads_a_field_listed_many_times_once() { // A field lists itself twice and its one widget twice: walked once per path to it, that is // 2^64 visits at the depth limit. - let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t)); let mut doc = Document::new_empty(); - let mut widget = Dict::new(); - widget.set(b"T".to_vec(), s("name")); - widget.set(crate::pdf::SOM_KEY.to_vec(), s("form1[0].name[0]")); - let widget = Object::Ref(doc.add(Object::Dict(widget))); + let widget = Object::Ref(som_field(&mut doc, "name", Some("form1[0].name[0]"), None)); let parent = doc.add(Object::Null); + let kids = Object::Array(vec![Object::Ref(parent), widget.clone(), Object::Ref(parent), widget]); + doc.set(parent, field_named("form1", None, Some(kids))); + let by_som = fields_by_som_of(doc, vec![Object::Ref(parent)]); + assert_eq!(by_som.len(), 1, "{by_som:?}"); + assert_eq!(by_som.get("form1[0].name[0]").map(String::as_str), Some("form1.name")); +} + +#[test] +fn fields_by_som_reads_a_field_listed_under_many_generations_once() { + // `Document::get` finds a field by object number alone, so `5 0 R` and `5 1 R` are one field. + // The AcroForm lists one under 2,000 generations: read again for each, its 1,000 kids would + // spend the walk's 2^20 steps before the field listed after it. + let mut doc = Document::new_empty(); + let leaf = som_field(&mut doc, "leaf", None, None); + let a = som_field(&mut doc, "a", None, Some(Object::Array(vec![Object::Ref(leaf); 1_000]))); + let b = som_field(&mut doc, "b", Some("form1[0].b[0]"), None); + let mut fields: Vec = (0..2_000).map(|g| Object::Ref(pdfcraft_cos::ObjRef::new(a.num, g))).collect(); + fields.push(Object::Ref(b)); + let by_som = fields_by_som_of(doc, fields); + assert_eq!(by_som.get("form1[0].b[0]").map(String::as_str), Some("b"), "{by_som:?}"); +} + +#[test] +fn fields_by_som_counts_kids_that_are_not_references() { + // 2,000 fields share one `/Kids` array of 1,000 nulls. Skipped uncounted, the nulls would + // cost nothing against the walk's 2^20 steps, however many fields share however long an + // array. Counted, they spend the steps here, and the walk stops before the last field. + let mut doc = Document::new_empty(); + let nulls = doc.add(Object::Array(vec![Object::Null; 1_000])); + let first = som_field(&mut doc, "first", Some("form1[0].first[0]"), None); + let last = som_field(&mut doc, "last", Some("form1[0].last[0]"), None); + let mut fields = vec![Object::Ref(first)]; + fields.extend((0..2_000).map(|i| Object::Ref(som_field(&mut doc, &format!("f{i}"), None, Some(Object::Ref(nulls)))))); + fields.push(Object::Ref(last)); + let by_som = fields_by_som_of(doc, fields); + assert!(by_som.contains_key("form1[0].first[0]"), "{by_som:?}"); + assert!(!by_som.contains_key("form1[0].last[0]"), "{by_som:?}"); +} + +/// An AcroForm field named `t`, with SOM path `som` and `kids` when given. +fn field_named(t: &str, som: Option<&str>, kids: Option) -> Object { + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t)); let mut d = Dict::new(); - d.set(b"T".to_vec(), s("form1")); - d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(parent), widget.clone(), Object::Ref(parent), widget])); - doc.set(parent, Object::Dict(d)); + d.set(b"T".to_vec(), s(t)); + if let Some(som) = som { + d.set(crate::pdf::SOM_KEY.to_vec(), s(som)); + } + if let Some(kids) = kids { + d.set(b"Kids".to_vec(), kids); + } + Object::Dict(d) +} + +/// [`field_named`], added to `doc`. +fn som_field(doc: &mut Document, t: &str, som: Option<&str>, kids: Option) -> pdfcraft_cos::ObjRef { + doc.add(field_named(t, som, kids)) +} + +/// [`fields_by_som`] of `doc`, its catalog's AcroForm listing `fields`. +fn fields_by_som_of(mut doc: Document, fields: Vec) -> std::collections::HashMap { let mut acro = Dict::new(); - acro.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)])); + acro.set(b"Fields".to_vec(), Object::Array(fields)); let mut cat = Dict::new(); cat.set(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())); cat.set(b"AcroForm".to_vec(), Object::Dict(acro)); let cat = doc.add(Object::Dict(cat)); doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat)); - let by_som = fields_by_som(&doc); - assert_eq!(by_som.len(), 1, "{by_som:?}"); - assert_eq!(by_som.get("form1[0].name[0]").map(String::as_str), Some("form1.name")); + fields_by_som(&doc) } From 1503682b573cc2354269fd69faef876002a44ca4 Mon Sep 17 00:00:00 2001 From: lowtide-lab <54317404+lowtide-lab@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:54:30 +0100 Subject: [PATCH 4/5] fix(xfa): the signed field appearance search counts against the kids read limit --- crates/xfa/src/pdf.rs | 46 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 6be47b7dba..c18749ad09 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -146,7 +146,7 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(); if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) { // The appearance is on the field when it is its own widget, or on its one widget. - let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); + let appearance = d.get(b"AP").cloned().or_else(|| walk.find_map(kids, |k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); } if depth < MAX_FIELD_DEPTH { @@ -168,6 +168,22 @@ struct FieldQueue { } impl FieldQueue { + /// The first of `kids` for which `f` gives a value. The entries looked at count against + /// [`MAX_KIDS_READ`] like those queued: signed fields sharing one long `/Kids` would + /// otherwise each search all of it. Past the limit nothing more is looked at. + fn find_map(&mut self, kids: &[Object], mut f: impl FnMut(&Object) -> Option) -> Option { + for k in kids { + self.read += 1; + if self.read > MAX_KIDS_READ { + return None; + } + if let Some(v) = f(k) { + return Some(v); + } + } + None + } + /// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are /// indirect references (ISO 32000-2 §12.7.3.1); anything else is skipped. A field listed /// again, by this list or another, under any generation, takes no room, and at most @@ -1057,6 +1073,34 @@ mod tests { assert!(names.contains("c.plain")); } + #[test] + fn signed_fields_sharing_one_long_kids_array_search_it_within_the_read_limit() { + // 1,000 signed fields share one `/Kids` array of 100,000 nulls and a widget with an + // appearance at its end. Each searched the whole array for that appearance: with a + // million nulls, a billion lookups (39 s). The search counts against `MAX_KIDS_READ`, so + // the fields read once it is spent keep their value but get no appearance. + let mut doc = Document::new_empty(); + let mut widget = Dict::new(); + widget.set(b"AP".to_vec(), Object::Dict(Dict::new())); + let mut shared = vec![Object::Null; 100_000]; + shared.push(Object::Ref(doc.add(Object::Dict(widget)))); + let kids = doc.add(Object::Array(shared)); + let fields = (0..1_000) + .map(|i| { + let mut d = Dict::new(); + d.set(b"T".to_vec(), Object::String(PdfString::literal(format!("s{i}").as_str()))); + d.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec())); + d.set(b"V".to_vec(), Object::Dict(Dict::new())); + d.set(b"Kids".to_vec(), Object::Ref(kids)); + Object::Ref(doc.add(Object::Dict(d))) + }) + .collect(); + let (signed, _) = existing(&doc, fields); + assert_eq!(signed.len(), 1_000); + let with_appearance = signed.values().filter(|s| s.appearance.is_some()).count(); + assert!((1..=10).contains(&with_appearance), "{with_appearance} appearances found"); + } + /// Field `c` with two kids: `plain`, and `sig`, a signed signature field. fn signed_parent(doc: &mut Document) -> ObjRef { let plain = doc.add(field("plain", Object::Array(Vec::new()))); From 367a728b3b283bc9f76114fcf6ed4950b89c2a98 Mon Sep 17 00:00:00 2001 From: lowtide-lab <54317404+lowtide-lab@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:25:24 +0100 Subject: [PATCH 5/5] fix(xfa): the appearance search has its own read limit, and a field reached shallower is read again The search for a signed field's appearance spent the read limit queuing its kids needed, so a long /Kids could hide a signed kid. And a field first reached at the depth limit was never read again from a shorter path, so the fields under it were lost; it is now read again when reached shallower, at most once per depth, within the same limits. --- crates/xfa/src/pdf.rs | 83 ++++++++++++++++++++++++++++++++-------- crates/xfa/src/script.rs | 13 ++++--- crates/xfa/src/tests.rs | 17 ++++++++ 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index c18749ad09..24b286d0d3 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -161,20 +161,26 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, #[derive(Default)] struct FieldQueue { stack: Vec<(ObjRef, String, bool, usize)>, - /// Object numbers queued: [`Document::get`] finds a field by number alone, so `5 0 R` and - /// `5 1 R` are the same field. - seen: HashSet, + /// The shallowest depth each object number was queued at: [`Document::get`] finds a field + /// by number alone, so `5 0 R` and `5 1 R` are the same field. + seen: HashMap, + /// Entries pushed onto `stack`, a field queued again at a shallower depth included. + pushed: usize, + /// `/Fields` and `/Kids` entries read while queuing. read: usize, + /// `/Kids` entries read while searching for a signed field's appearance. + searched: usize, } impl FieldQueue { - /// The first of `kids` for which `f` gives a value. The entries looked at count against - /// [`MAX_KIDS_READ`] like those queued: signed fields sharing one long `/Kids` would - /// otherwise each search all of it. Past the limit nothing more is looked at. + /// The first of `kids` for which `f` gives a value. At most [`MAX_KIDS_READ`] entries are + /// looked at in all: signed fields sharing one long `/Kids` would otherwise each search all + /// of it. The searches have a budget of their own, apart from the one for queuing, so a + /// search can never leave a field's kids unqueued. Past the limit nothing more is looked at. fn find_map(&mut self, kids: &[Object], mut f: impl FnMut(&Object) -> Option) -> Option { for k in kids { - self.read += 1; - if self.read > MAX_KIDS_READ { + self.searched += 1; + if self.searched > MAX_KIDS_READ { return None; } if let Some(v) = f(k) { @@ -186,18 +192,22 @@ impl FieldQueue { /// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are /// indirect references (ISO 32000-2 §12.7.3.1); anything else is skipped. A field listed - /// again, by this list or another, under any generation, takes no room, and at most - /// [`MAX_FIELDS_SEARCHED`] are ever queued: a field listing one kid many times, or fields - /// sharing one long `/Kids`, would otherwise queue it all on every visit. Reading the repeats - /// costs too, so at most [`MAX_KIDS_READ`] entries are read in all. + /// again, by this list or another, under any generation, takes no room, unless it is now + /// shallower than where it was queued: first reached at [`MAX_FIELD_DEPTH`], its kids would + /// otherwise never be read. So a field is queued at most once per depth, and at most + /// [`MAX_FIELDS_SEARCHED`] entries are ever queued: a field listing one kid many times, or + /// fields sharing one long `/Kids`, would otherwise queue it all on every visit. Reading the + /// repeats costs too, so at most [`MAX_KIDS_READ`] entries are read in all. fn queue(&mut self, kids: &[Object], prefix: &str, is_sig: bool, depth: usize) { for k in kids { self.read += 1; - if self.seen.len() >= MAX_FIELDS_SEARCHED || self.read > MAX_KIDS_READ { + if self.pushed >= MAX_FIELDS_SEARCHED || self.read > MAX_KIDS_READ { return; } let Some(r) = k.as_ref() else { continue }; - if self.seen.insert(r.num) { + if self.seen.get(&r.num).is_none_or(|&queued| depth < queued) { + self.seen.insert(r.num, depth); + self.pushed += 1; self.stack.push((r, prefix.to_string(), is_sig, depth)); } } @@ -1011,7 +1021,7 @@ mod tests { use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; - use super::{MAX_FIELDS_SEARCHED, Signed, existing_fields, parse_hex32}; + use super::{MAX_FIELD_DEPTH, MAX_FIELDS_SEARCHED, MAX_KIDS_READ, Signed, existing_fields, parse_hex32}; /// The fields `existing_fields` finds under an AcroForm listing `fields`. fn names_found(doc: &Document, fields: Vec) -> Vec { @@ -1101,6 +1111,49 @@ mod tests { assert!((1..=10).contains(&with_appearance), "{with_appearance} appearances found"); } + #[test] + fn an_appearance_search_leaves_the_kids_of_a_signed_field_queued() { + // A signed field with no appearance of its own lists a signed kid and then + // `MAX_KIDS_READ` nulls. Searching them all for an appearance must not spend what + // queuing its kids has to read, or the signed kid is never found. + let mut doc = Document::new_empty(); + let mut kid = Dict::new(); + kid.set(b"T".to_vec(), Object::String(PdfString::literal("c"))); + kid.set(b"V".to_vec(), Object::Dict(Dict::new())); + let mut kids = vec![Object::Ref(doc.add(Object::Dict(kid)))]; + kids.extend(std::iter::repeat_n(Object::Null, MAX_KIDS_READ)); + let mut parent = Dict::new(); + parent.set(b"T".to_vec(), Object::String(PdfString::literal("p"))); + parent.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec())); + parent.set(b"V".to_vec(), Object::Dict(Dict::new())); + parent.set(b"Kids".to_vec(), Object::Array(kids)); + let parent = doc.add(Object::Dict(parent)); + let (signed, _) = existing(&doc, vec![Object::Ref(parent)]); + assert!(signed.contains_key("p.c"), "{:?}", signed.keys().collect::>()); + } + + #[test] + fn a_field_reached_first_at_the_depth_limit_is_read_again_when_reached_shallower() { + // Field `s` is listed at the bottom of a chain `MAX_FIELD_DEPTH` deep, which is walked + // first, and again under `b`. Reached first where its kids are not read, it is read + // again under `b`, and the signed field under it is found. + let mut doc = Document::new_empty(); + let mut sig = Dict::new(); + sig.set(b"T".to_vec(), Object::String(PdfString::literal("sig"))); + sig.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec())); + sig.set(b"V".to_vec(), Object::Dict(Dict::new())); + let sig = doc.add(Object::Dict(sig)); + let shared = doc.add(field("s", Object::Array(vec![Object::Ref(sig)]))); + let mut deep = shared; + for i in (0..MAX_FIELD_DEPTH).rev() { + deep = doc.add(field(&format!("d{i}"), Object::Array(vec![Object::Ref(deep)]))); + } + let b = doc.add(field("b", Object::Array(vec![Object::Ref(shared)]))); + let (signed, names) = existing(&doc, vec![Object::Ref(b), Object::Ref(deep)]); + assert!(signed.contains_key("b.s.sig"), "{:?}", signed.keys().collect::>()); + assert!(names.contains("b.s")); + } + /// Field `c` with two kids: `plain`, and `sig`, a signed signature field. fn signed_parent(doc: &mut Document) -> ObjRef { let plain = doc.add(field("plain", Object::Array(Vec::new()))); diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index b9ee8cdcb8..bcd815a411 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -2,7 +2,7 @@ //! events a template declares, the presence overrides scripts make (kept in the PDF so they //! survive undo and reopening), and laying the form out again after scripts changed it. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap}; use pdfcraft_cos::{Dict, Document, Object, PdfString}; @@ -549,12 +549,14 @@ pub fn fields_by_som(doc: &Document) -> HashMap { // Each field is read once: a field listed twice in `/Kids` (or among its own kids) would // otherwise be walked again for every path to it, doubling the work at each level. Fields // are told apart by object number, as `Document::get` finds them, so `5 0 R` and `5 1 R` - // are one. `steps` counts every entry looked at, references or not, so fields sharing one + // are one. A field is read again only when it is reached shallower than before: first reached + // at `MAX_DEPTH`, its kids would otherwise never be read. `steps` counts every entry looked at, references or not, so fields sharing one // long `/Kids` array stay bounded too; `walk_all` returns false once it is spent, and every // loop then stops. struct Walk { out: HashMap, - seen: HashSet, + /// The shallowest depth each object number was read at. + seen: HashMap, steps: usize, } fn walk_all(doc: &Document, kids: &[Object], prefix: &str, w: &mut Walk, depth: usize) -> bool { @@ -567,9 +569,10 @@ pub fn fields_by_som(doc: &Document) -> HashMap { }) } fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) -> bool { - if depth > MAX_DEPTH || !w.seen.insert(r.num) { + if depth > MAX_DEPTH || w.seen.get(&r.num).is_some_and(|&read| read <= depth) { return true; } + w.seen.insert(r.num, depth); let o = doc.get(r); let Some(d) = o.as_dict() else { return true }; let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text())); @@ -589,7 +592,7 @@ pub fn fields_by_som(doc: &Document) -> HashMap { } let Some((_, acro)) = acroform(doc) else { return HashMap::new() }; let fields = acro.get(b"Fields").map(|f| doc.resolve(f)); - let mut w = Walk { out: HashMap::new(), seen: HashSet::new(), steps: 0 }; + let mut w = Walk { out: HashMap::new(), seen: HashMap::new(), steps: 0 }; walk_all(doc, fields.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), "", &mut w, 0); w.out } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 552484fb18..80f2efc951 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4136,6 +4136,23 @@ fn fields_by_som_counts_kids_that_are_not_references() { assert!(!by_som.contains_key("form1[0].last[0]"), "{by_som:?}"); } +#[test] +fn fields_by_som_reads_a_field_again_when_it_is_reached_shallower() { + // Field `s` is listed at the bottom of a chain 64 deep, which is walked first, and again + // under `b`. Reached first where its kids are past the depth limit, it is read again under + // `b`, and the field under it is found by its SOM path. + let mut doc = Document::new_empty(); + let leaf = som_field(&mut doc, "leaf", Some("form1[0].leaf[0]"), None); + let shared = som_field(&mut doc, "s", None, Some(Object::Array(vec![Object::Ref(leaf)]))); + let mut deep = shared; + for i in (0..64).rev() { + deep = som_field(&mut doc, &format!("d{i}"), None, Some(Object::Array(vec![Object::Ref(deep)]))); + } + let b = som_field(&mut doc, "b", None, Some(Object::Array(vec![Object::Ref(shared)]))); + let by_som = fields_by_som_of(doc, vec![Object::Ref(deep), Object::Ref(b)]); + assert_eq!(by_som.get("form1[0].leaf[0]").map(String::as_str), Some("b.s.leaf"), "{by_som:?}"); +} + /// An AcroForm field named `t`, with SOM path `som` and `kids` when given. fn field_named(t: &str, som: Option<&str>, kids: Option) -> Object { let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t));