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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-07 12:37:35 +01:00
1 parent 0a350f6809
commit 8bf7412220
2 files changed
+118 -37

No files matched your search

+55 -37
View File
@@ -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<Owner>,
}
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<usize, Organiz
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
forms_seen: HashSet::new(),
partly_read: HashSet::new(),
};
for (page, attrs) in &pages {
let Some(res) = attrs.get(b"Resources") else { continue };
@@ -330,11 +352,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
match content_bytes(pruner.doc, &contents) {
Ok((data, false)) if data.is_empty() => {}
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;
+63
View File
@@ -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<String> = 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]