M2.6: Extract and Split prune nothing past the form depth limit

Pruning reads forms 8 deep. Past that a form's /XObject dictionary was kept whole, but the forms
it draws weren't read, nor what they draw through resources of their own. When those resources
are shared with content that was read, they were cut to the names that content draws, and the
images only the deep forms draw were dropped from the part. This was so on `main` for a form read
whole, and in this change's reading of a damaged form too.

A walk the depth limit stops now leaves the file unpruned, as an overlarge Type 3 resource graph
already does: `nothing_is_pruned_past_the_depth_limit` (a form 9 deep, whole or damaged partway,
drawing a form that draws through resources the second page shares). That also settles a form
first reached past the limit and drawn again higher up, whose second reading `forms_seen` skips.
Down to the limit pruning goes on, under a page whole or damaged partway and with the form 8 deep
whole or damaged: `pruning_goes_on_down_to_the_depth_limit`.

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 18:49:17 +01:00
1 parent 86cb375869
commit ae677644e0
2 files changed
+89 -11

No files matched your search

+15 -7
View File
@@ -40,12 +40,15 @@ enum Owner {
struct Pruner<'a> {
doc: &'a Document,
used: HashMap<XKey, HashSet<Vec<u8>>>,
/// Dictionaries whose drawn names can't be known for certain (undecodable content, nesting
/// past the depth limit, Type 3 glyphs drawing through them). They are left untouched:
/// pruning must never drop an XObject something still draws.
/// Dictionaries whose drawn names can't be known for certain (undecodable content, Type 3
/// glyphs drawing through them). They are left untouched: pruning must never drop an XObject
/// something still draws.
unsure: HashSet<XKey>,
/// An overlarge glyph-resource graph is not safe to prune at all.
glyph_budget_exhausted: bool,
/// Nor is a file with forms nested past [`MAX_FORM_DEPTH`]: what the forms there draw isn't
/// read, and they may draw it through resources shared with content that was.
too_deep: bool,
glyph_work_left: usize,
glyph_seen: HashSet<ObjRef>,
/// An indirect font's explicit resources have one owner regardless of which page uses it.
@@ -188,7 +191,7 @@ impl Pruner<'_> {
return;
};
if depth > MAX_FORM_DEPTH {
self.unsure.insert(key);
self.too_deep = true;
return;
}
for op in pdfcraft_content::parse(data).ops {
@@ -209,7 +212,11 @@ impl Pruner<'_> {
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) {
if depth > MAX_FORM_DEPTH {
self.too_deep = true;
return;
}
if !self.partly_read.insert(owner) {
return;
}
for (name, _) in xobjects.iter() {
@@ -341,6 +348,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
used: HashMap::new(),
unsure: HashSet::new(),
glyph_budget_exhausted: false,
too_deep: false,
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
@@ -358,8 +366,8 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
Ok((_, true)) | Err(_) => pruner.unread(&res_dict, owner, 0),
}
}
let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner;
if glyph_budget_exhausted {
let Pruner { used, unsure, glyph_budget_exhausted, too_deep, .. } = pruner;
if glyph_budget_exhausted || too_deep {
return Ok(0);
}
let mut removed = 0;
+74 -4
View File
@@ -2733,8 +2733,8 @@ fn extract_keeps_images_forms_drawn_around_damage_draw_through_shared_resources(
}
/// Forms are read 8 deep (`prune.rs`'s `MAX_FORM_DEPTH`). A damaged form that deep still has
/// every form it names read as drawn, and one drawing through resources another page shares keeps
/// what it draws from them.
/// every form it names read as drawn: here `/Nested`, which draws through resources another page
/// shares. Read 9 deep, it leaves the file unpruned, so what it draws from them stays.
#[test]
fn a_damaged_form_at_the_depth_limit_has_its_forms_read() {
use pdfcraft_cos::ObjRef;
@@ -2760,8 +2760,78 @@ fn a_damaged_form_at_the_depth_limit_has_its_forms_read() {
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{names:?}");
}
/// Past the depth limit a form isn't read, nor what the forms it draws draw: one of those may draw
/// through resources another page shares, and keep images there that page doesn't draw, so nothing
/// is pruned. Here `/Fm` is 9 deep, whole or damaged partway, and `/Nested`, which it draws, draws
/// `/ImA` through the resources the second page draws `/ImB` through.
#[test]
fn nothing_is_pruned_past_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
for (what, fm) in [("whole", whole(b"/Nested Do")), ("damaged", damaged_flate(b"0 g\n", b"/Nested Do"))] {
let mut src = doc_shared_form_resources(whole(b"/C Do"), fm, whole(b"/ImA Do"));
// Eight forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 9 deep.
let mut next = ObjRef::new(7, 0);
for _ in 0..8 {
let mut form = whole(b"/C Do");
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()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
let names = xobject_names(&part, 1);
assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "{what}: {names:?}");
}
}
/// Down to the depth limit pruning goes on: under a page whole or damaged partway, a form 8 deep,
/// whole or damaged, is read, and the image nothing draws, `/ImA`, still goes from the resources
/// the second page draws `/ImB` through.
#[test]
fn pruning_goes_on_down_to_the_depth_limit() {
use pdfcraft_cos::ObjRef;
let whole = |content: &[u8]| pdfcraft_cos::Stream::from_raw(Dict::new(), content.to_vec());
let cases = [
("whole", whole(b"/C Do"), whole(b"0 g")),
("damaged form", whole(b"/C Do"), damaged_flate(b"0 g\n", b"0 g")),
("damaged page", damaged_flate(b"/C Do\n", b"0 g"), whole(b"0 g")),
];
for (what, page, fm) in cases {
let mut src = doc_shared_form_resources(page, fm, whole(b""));
// `/Fm` names an image, not `/Nested`: nothing is read 9 deep.
let image: Dict = [(b"Img".to_vec(), Object::Ref(ObjRef::new(9, 0)))].into_iter().collect();
let resources = Object::Dict([(b"XObject".to_vec(), Object::Dict(image))].into_iter().collect());
src.update_dict(ObjRef::new(7, 0), |f| f.set(b"Resources".to_vec(), resources)).unwrap();
// Seven forms between the page and `/Fm`, each drawing the next as `/C`: `/Fm` is 8 deep.
let mut next = ObjRef::new(7, 0);
for _ in 1..8 {
let mut form = whole(b"/C Do");
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()]));
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
form.dict.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()));
next = src.add(Object::Stream(form));
}
let names: Dict = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
src.update_dict(ObjRef::new(3, 0), |p| {
p.set(b"Resources".to_vec(), Object::Dict([(b"XObject".to_vec(), Object::Dict(names))].into_iter().collect()))
})
.unwrap();
let part = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap());
assert_eq!(xobject_names(&part, 1), [b"ImB".to_vec()], "{what}");
}
}
/// A chain of forms nested far past the depth limit, whole or each damaged partway, is read only
/// down to it: the walk's depth stays bounded however long the chain is, and pruning goes on.
/// down to it: the walk's depth stays bounded however long the chain is, and the run ends without
/// an error, having pruned nothing.
#[test]
fn a_long_chain_of_forms_is_read_only_to_the_depth_limit() {
use pdfcraft_cos::ObjRef;
@@ -2779,7 +2849,7 @@ fn a_long_chain_of_forms_is_read_only_to_the_depth_limit() {
names = [(b"C".to_vec(), Object::Ref(next))].into_iter().collect();
}
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Resources".to_vec(), xobjects(names))).unwrap();
assert!(crate::prune::prune_unused_xobjects(&mut doc).is_ok(), "damaged: {damaged}");
assert!(matches!(crate::prune::prune_unused_xobjects(&mut doc), Ok(0)), "damaged: {damaged}");
}
}