fix(organize): analyse each form once when pruning unused XObjects

The pruner decoded and walked a form XObject again on every Do that drew
it, so a form drawing itself a few times per level was analysed
exponentially often within the depth limit. Forms are now remembered with
the resources owner they were drawn against, and a repeat records nothing
new.
This commit is contained in:
aiongg
2026-10-22 21:16:20 +00:00
parent cab1dc3eb5
commit 1f6ce5ecc7
2 changed files with 35 additions and 1 deletions
+10 -1
View File
@@ -29,7 +29,7 @@ enum XKey {
}
/// Who owns the resources currently being walked, for the key drawn names are recorded under.
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Owner {
/// An indirect resources object (a page's, or a form XObject's own).
Resources(ObjRef),
@@ -50,6 +50,9 @@ struct Pruner<'a> {
glyph_seen: HashSet<ObjRef>,
/// An indirect font's explicit resources have one owner regardless of which page uses it.
glyph_fonts: HashSet<ObjRef>,
/// Forms already analysed, with the owner of the resources they drew against: drawing one
/// again records nothing new, and re-reading it on every `Do` is exponential in the nesting.
forms_seen: HashSet<(ObjRef, Owner)>,
}
impl Pruner<'_> {
@@ -211,6 +214,11 @@ impl Pruner<'_> {
// A form without its own resources draws against the ones it was invoked with.
None => (resources.clone(), owner),
};
if !self.forms_seen.insert((r, inner_owner)) {
continue;
}
#[cfg(test)]
crate::work(1);
match s.decoded() {
Ok(data) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown: keep everything it could reach.
@@ -314,6 +322,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
forms_seen: HashSet::new(),
};
for (page, attrs) in &pages {
let Some(res) = attrs.get(b"Resources") else { continue };
+25
View File
@@ -3221,3 +3221,28 @@ fn name_trees_with_shared_kids_are_walked_once_per_node() {
let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
assert_eq!(tree.get(b"Names").and_then(|n| n.as_array()).map(Vec::len), Some(2), "one attachment, listed once");
}
#[test]
fn a_form_drawing_itself_repeatedly_is_analysed_once() {
let form = "/F Do /F Do /F Do /F Do";
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /F 5 0 R /Unused 6 0 R >> >> >>".into(),
"<< /Length 5 >>\nstream\n/F Do\nendstream".into(),
format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /F 5 0 R >> >> /Length {} >>\nstream\n{form}\nendstream",
form.len()
),
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 0 >>\nstream\n\nendstream".into(),
];
let src = open(build(&b, "/Root 1 0 R"));
take_work();
let out = extract_pages(&src, &[0]).unwrap();
let work = take_work();
assert!(work <= 4, "{work} form analyses");
let page = out.get(pages(&out).unwrap()[0].obj).as_dict().cloned().unwrap();
let res = out.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
let x = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
assert!(x.contains(b"F") && !x.contains(b"Unused"), "pruning still keeps what is drawn and drops the rest");
}