mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
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:
1 parent
86cb375869
commit
ae677644e0
2 files changed
+89
-11
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user