From 8bf741222016dc9fd997f96100897edc95c4049e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 6 Nov 2026 01:01:24 +0100 Subject: [PATCH] M2.6: pruning reads every form that damaged content may draw Earlier in this change, a page or form damaged partway, like one that doesn't decode, kept its own /XObject dictionary whole and was read no further. So a form it draws was never read: when the form's resources are shared with another page, the names the form draws there went, and extracted or split pages lost those images. On main the part before the damage was read, so forms drawn there kept theirs, and forms drawn past it didn't. Now every form the resources of such content name is read as drawn, as any of them may be drawn before the damage or past it. Each set of resources has its forms read once. Test: extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources, with a page and a form each damaged before and past the form they draw, and each not decoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/organize/src/prune.rs | 92 +++++++++++++++++++++--------------- crates/organize/src/tests.rs | 63 ++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 37 deletions(-) diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index 591e804b1e..bac8ad5f90 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -53,6 +53,9 @@ struct Pruner<'a> { /// 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)>, + /// Owners of resources that content not read whole drew against, whose every form was read + /// as drawn ([`Pruner::unread`]): once is enough. + partly_read: HashSet, } impl Pruner<'_> { @@ -194,39 +197,57 @@ impl Pruner<'_> { } let Some(name) = op.name(0) else { continue }; self.used.entry(key).or_default().insert(name.to_vec()); - let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue }; - let Object::Stream(s) = &*self.doc.get(r) else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") { - continue; - } - let (inner, inner_owner) = match s.dict.get(b"Resources") { - Some(res) => match res.as_ref() { - Some(rr) => match self.doc.get(rr).as_dict().cloned() { - Some(d) => (d, Owner::Resources(rr)), - None => continue, - }, - None => match self.doc.resolve(res).as_dict().cloned() { - // The form carries its resources inline: filter them on the form itself. - Some(d) => (d, Owner::Resources(r)), - None => continue, - }, + self.form(&xobjects, name, resources, owner, depth); + } + } + + /// For content that can't be read whole (damaged partway, or not decoding at all): what it + /// draws is unknown, so its `/XObject` dictionary is kept whole, and every form there is read + /// as drawn, which also reads every form the part before any damage draws. A form whose + /// resources are shared keeps what it draws from them too. + fn unread(&mut self, resources: &Dict, owner: Owner, depth: u8) { + self.protect_type3_resources(resources, owner); + let Some((xobjects, key)) = self.xobjects(resources, owner) else { return }; + self.unsure.insert(key); + if depth > MAX_FORM_DEPTH || !self.partly_read.insert(owner) { + return; + } + for (name, _) in xobjects.iter() { + self.form(&xobjects, name, resources, owner, depth); + } + } + + /// Record what the XObject `name` of `xobjects` draws when it is a form, drawn from content + /// with `resources`. + fn form(&mut self, xobjects: &Dict, name: &[u8], resources: &Dict, owner: Owner, depth: u8) { + let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { return }; + let Object::Stream(s) = &*self.doc.get(r) else { return }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return; + } + let (inner, inner_owner) = match s.dict.get(b"Resources") { + Some(res) => match res.as_ref() { + Some(rr) => match self.doc.get(rr).as_dict().cloned() { + Some(d) => (d, Owner::Resources(rr)), + None => return, }, - // 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 DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) { - Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1), - // What the form draws is unknown, past damage too: keep everything it could reach. - Ok((_, true)) | Err(_) => { - self.protect_type3_resources(&inner, inner_owner); - self.distrust(&inner, inner_owner); - } - } + None => match self.doc.resolve(res).as_dict().cloned() { + // The form carries its resources inline: filter them on the form itself. + Some(d) => (d, Owner::Resources(r)), + None => return, + }, + }, + // 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)) { + return; + } + #[cfg(test)] + crate::work(1); + match DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) { + Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1), + Ok((_, true)) | Err(_) => self.unread(&inner, inner_owner, depth + 1), } } } @@ -321,6 +342,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result Result {} Ok((data, false)) => pruner.content(&data, &res_dict, owner, 0), - // What the page draws past damage is unknown too. - Ok((_, true)) | Err(_) => { - pruner.protect_type3_resources(&res_dict, owner); - pruner.distrust(&res_dict, owner); - } + Ok((_, true)) | Err(_) => pruner.unread(&res_dict, owner, 0), } } let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 8d41f474cc..d6273cc65b 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2669,6 +2669,69 @@ fn extract_keeps_images_content_damaged_partway_might_draw() { } } +/// Two pages: the first draws `/Fm` through resources of its own, `/Fm` draws `/Nested` through +/// resources of its own too, and `/Nested` draws `/ImA` through resources it shares with the second +/// page, which draws `/ImB` through them. The three streams are the first page's content, `/Fm` +/// and `/Nested`. +fn doc_shared_form_resources(page: pdfcraft_cos::Stream, fm: pdfcraft_cos::Stream, nested: pdfcraft_cos::Stream) -> Document { + use pdfcraft_cos::ObjRef; + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Fm 7 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 8 0 R >>".into(), + raw(""), + raw("/ImB Do"), + raw(""), + "<< /XObject << /ImA 9 0 R /ImB 10 0 R >> >>".into(), + image(1, 1, 1), + image(1, 1, 2000), + raw(""), + ]; + let mut doc = open(build(&b, "/Root 1 0 R")); + doc.set(ObjRef::new(5, 0), page); + let nested_res: Dict = + [(b"XObject".to_vec(), Object::Dict([(b"Nested".to_vec(), Object::Ref(ObjRef::new(11, 0)))].into_iter().collect()))].into_iter().collect(); + for (num, mut form, resources) in [(7, fm, Object::Dict(nested_res)), (11, nested, Object::Ref(ObjRef::new(8, 0)))] { + form.dict.set(b"Subtype".to_vec(), Object::name("Form")); + form.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + form.dict.set(b"Resources".to_vec(), resources); + doc.set(ObjRef::new(num, 0), form); + } + doc +} + +/// Every form the resources of a page or form damaged partway name may be drawn, before the +/// damage or past it, so each is read: one whose resources another page shares keeps what it draws +/// from them. So is each one content that doesn't decode may draw. +#[test] +fn extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources() { + let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec()); + let undecodable = |content: &[u8]| { + let mut s = whole(content); + s.dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + s + }; + let cases = [ + ("page, before the damage", damaged_flate(b"/Fm Do\n", b"0 g"), whole(b"/Nested Do")), + ("page, past the damage", damaged_flate(b"0 g\n", b"/Fm Do"), whole(b"/Nested Do")), + ("page that doesn't decode", undecodable(b"/Fm Do"), whole(b"/Nested Do")), + ("form, before the damage", whole(b"/Fm Do"), damaged_flate(b"/Nested Do\n", b"0 g")), + ("form, past the damage", whole(b"/Fm Do"), damaged_flate(b"0 g\n", b"/Nested Do")), + ("form that doesn't decode", whole(b"/Fm Do"), undecodable(b"/Nested Do")), + ]; + for (what, page, fm) in cases { + let part = full_roundtrip(&extract_pages(&doc_shared_form_resources(page, fm, whole(b"/ImA Do")), &[0, 1]).unwrap()); + let names = xobject_names(&part, 1); + assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}"); + assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{what}: {names:?}"); + } + // What nothing draws still goes. + let src = doc_shared_form_resources(whole(b"0 g"), whole(b"/Nested Do"), whole(b"/ImA Do")); + let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()]); +} + /// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is /// kept, not lost to content read with the predictor's tag bytes left in it. #[test]