From dfb4e26e1ac675d0f8cd785100c81dc0ec84e0ae Mon Sep 17 00:00:00 2001 From: munzir Date: Thu, 8 Oct 2026 10:42:27 +0300 Subject: [PATCH] Split: keep only the resources each part draws (#204) (#228) * Split: keep only the resources each part draws (#204) In a book whose pages share one resource dictionary, every split part carried every XObject the source listed: a part saved out at the source's size although its pages drew a fraction of the images. extract_pages now walks each part's content (form XObjects included) and drops the /XObject entries no page names; the garbage-collecting rewrite on save then drops the objects themselves. Tests cover a one-page part that must lose the other page's image and a part that needs both. * Split/Extract pruning never drops an XObject something might still draw A resources dictionary is left untouched when one of its pages has content that can't be decoded, when a form nests past the depth limit, or when it lists a Type 3 font without its own /Resources (its glyphs draw through the page's). Regression tests fail without this. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Mohammed Munzir Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + crates/organize/Cargo.toml | 1 + crates/organize/src/import.rs | 3 + crates/organize/src/lib.rs | 1 + crates/organize/src/prune.rs | 225 ++++++++++++++++++++++++++++++++++ crates/organize/src/tests.rs | 91 ++++++++++++++ 6 files changed, 322 insertions(+) create mode 100644 crates/organize/src/prune.rs diff --git a/Cargo.lock b/Cargo.lock index f43b4840..7b66287d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3694,6 +3694,7 @@ name = "pdfcraft-organize" version = "0.2.1" dependencies = [ "hayro-syntax", + "pdfcraft-content", "pdfcraft-cos", "thiserror 2.0.21", ] diff --git a/crates/organize/Cargo.toml b/crates/organize/Cargo.toml index c047afc3..e759a322 100644 --- a/crates/organize/Cargo.toml +++ b/crates/organize/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } thiserror = { workspace = true } diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index ddd4fa23..05c481f8 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -420,6 +420,9 @@ pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { + doc: &'a Document, + used: HashMap>>, + /// 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. + unsure: HashSet, +} + +impl Pruner<'_> { + /// Mark the `/XObject` dictionary of `resources` as one not to prune. + fn distrust(&mut self, resources: &Dict, owner: Owner) { + if let Some((_, key)) = self.xobjects(resources, owner) { + self.unsure.insert(key); + } + } + + /// True when `resources` lists a Type 3 font without its own `/Resources`: its glyph + /// procedures draw through these resources, and they are not followed here. + fn has_inheriting_type3(&self, resources: &Dict) -> bool { + let Some(fonts) = resources.get(b"Font").map(|f| self.doc.resolve(f)) else { return false }; + let Some(fonts) = fonts.as_dict() else { return false }; + fonts + .iter() + .any(|(_, f)| self.doc.resolve(f).as_dict().is_some_and(|fd| fd.name(b"Subtype") == Some(b"Type3") && fd.get(b"Resources").is_none())) + } + + /// The `/XObject` dictionary of `resources`, with the key its drawn names belong to. + fn xobjects(&self, resources: &Dict, owner: Owner) -> Option<(Dict, XKey)> { + let x = resources.get(b"XObject")?; + if let Some(r) = x.as_ref() { + return Some((self.doc.resolve(x).as_dict()?.clone(), XKey::XObject(r))); + } + let key = match owner { + Owner::Resources(r) => XKey::Resources(r), + Owner::Page(p) => XKey::Page(p), + }; + Some((self.doc.resolve(x).as_dict()?.clone(), key)) + } + + /// Record every XObject name `data` draws with `Do`, descending into form XObjects. + fn content(&mut self, data: &[u8], resources: &Dict, owner: Owner, depth: u8) { + let Some((xobjects, key)) = self.xobjects(resources, owner) else { + return; + }; + if depth > MAX_FORM_DEPTH || self.has_inheriting_type3(resources) { + self.unsure.insert(key); + return; + } + for op in pdfcraft_content::parse(data).ops { + if op.op.as_slice() != b"Do" { + continue; + } + 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, + }, + }, + // A form without its own resources draws against the ones it was invoked with. + None => (resources.clone(), owner), + }; + match s.decoded() { + Ok(data) => self.content(&data, &inner, inner_owner, depth + 1), + // What the form draws is unknown: keep everything it could reach. + Err(_) => self.distrust(&inner, inner_owner), + } + } + } +} + +/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a +/// stream can't be decoded: the page may draw names that can't be seen. +fn content_bytes(doc: &Document, contents: &Object) -> Option> { + match &*doc.resolve(contents) { + Object::Stream(s) => s.decoded().ok(), + Object::Array(a) => { + let mut out = Vec::new(); + for c in a { + if let Object::Stream(s) = &*doc.resolve(c) { + out.extend(s.decoded().ok()?); + out.push(b'\n'); + } + } + Some(out) + } + _ => Some(Vec::new()), + } +} + +/// The resources of a page (as `walk` reports them) with their owner. +fn resolve_resources(doc: &Document, resources: &Object, page: ObjRef) -> Option<(Dict, Owner)> { + match resources { + Object::Ref(r) => doc.get(*r).as_dict().cloned().map(|d| (d, Owner::Resources(*r))), + Object::Dict(d) => Some((d.clone(), Owner::Page(page))), + _ => None, + } +} + +/// Drop the entries `names` never draws. Returns how many were removed. +fn drop_unused(xobjects: &mut Dict, names: &HashSet>) -> usize { + let unused: Vec> = xobjects.iter().map(|(k, _)| k.clone()).filter(|k| !names.contains(k)).collect(); + for k in &unused { + xobjects.remove(k); + } + unused.len() +} + +/// Filter the dictionary `key` names to `names`. +fn filter_at(doc: &mut Document, key: XKey, names: &HashSet>) -> Result { + match key { + XKey::XObject(r) => { + let mut removed = 0; + doc.update_dict(r, |d| removed = drop_unused(d, names))?; + Ok(removed) + } + XKey::Resources(r) => { + let Some(d) = doc.get(r).as_dict().cloned() else { return Ok(0) }; + let Some(x) = d.get(b"XObject") else { return Ok(0) }; + if x.as_ref().is_some() { + return Ok(0); // recorded as XObject(r) instead + } + let Some(mut xd) = doc.resolve(x).as_dict().cloned() else { return Ok(0) }; + let removed = drop_unused(&mut xd, names); + if removed > 0 { + doc.update_dict(r, |d| d.set(b"XObject".to_vec(), Object::Dict(xd.clone())))?; + } + Ok(removed) + } + XKey::Page(p) => { + let Some(pd) = doc.get(p).as_dict().cloned() else { return Ok(0) }; + let Some(res) = pd.get(b"Resources") else { return Ok(0) }; + if res.as_ref().is_some() { + return Ok(0); // recorded as Resources(r) instead + } + let Some(mut rd) = doc.resolve(res).as_dict().cloned() else { return Ok(0) }; + let Some(x) = rd.get(b"XObject") else { return Ok(0) }; + if x.as_ref().is_some() { + return Ok(0); + } + let Some(mut xd) = doc.resolve(x).as_dict().cloned() else { return Ok(0) }; + let removed = drop_unused(&mut xd, names); + if removed > 0 { + rd.set(b"XObject".to_vec(), Object::Dict(xd)); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(rd.clone())))?; + } + Ok(removed) + } + } +} + +/// Drop the page-resource entries the document's pages never draw. Returns how many were removed. +pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result { + let pages = crate::walk(doc)?; + let mut pruner = Pruner { doc, used: HashMap::new(), unsure: HashSet::new() }; + for (page, attrs) in &pages { + let Some(res) = attrs.get(b"Resources") else { continue }; + let Some((res_dict, owner)) = resolve_resources(pruner.doc, res, *page) else { continue }; + let Some(pd) = pruner.doc.get(*page).as_dict().cloned() else { continue }; + let Some(contents) = pd.get(b"Contents").cloned() else { continue }; + match content_bytes(pruner.doc, &contents) { + Some(data) if data.is_empty() => {} + Some(data) => pruner.content(&data, &res_dict, owner, 0), + None => pruner.distrust(&res_dict, owner), + } + } + let Pruner { used, unsure, .. } = pruner; + let mut removed = 0; + for (key, names) in used { + if !unsure.contains(&key) { + removed += filter_at(doc, key, &names)?; + } + } + Ok(removed) +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index f8c30ca4..16273e44 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -713,3 +713,94 @@ fn initial_view_round_trips_and_keeps_scripts() { assert!(doc.get(root).as_dict().unwrap().get(b"OpenAction").and_then(|o| o.as_dict()).is_some_and(|d| d.name(b"S") == Some(b"JavaScript"))); assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err()); } + +// ── Split / Extract keep only the resources the part draws (#204) ────────────────────────────── + +/// A content stream with no text operators (an image draw). +fn raw(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +fn image(width: u32, height: u32, payload: usize) -> String { + let data = "x".repeat(payload); + format!( + "<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {payload} >>\nstream\n{data}\nendstream" + ) +} + +/// Document D: two pages sharing one resources dictionary with two image XObjects; each page +/// draws a different one — the shared-dictionary shape of #204. +fn doc_images() -> Document { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] /Resources 7 0 R >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), // 4 + raw("/ImA Do"), // 5 + raw("/ImB Do"), // 6 + "<< /XObject << /ImA 8 0 R /ImB 9 0 R >> >>".into(), // 7 + image(1, 1, 1), // 8 + image(1, 1, 2000), // 9 + ]; + open(build(&b, "/Root 1 0 R")) +} + +fn xobject_names(doc: &Document, page: usize) -> Vec> { + let resources = page_dict(doc, page).get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).expect("page resources"); + let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).expect("xobject dict"); + xobjects.iter().map(|(k, _)| k.clone()).collect() +} + +#[test] +fn extract_keeps_only_the_images_the_part_draws() { + let src = doc_images(); + let whole = write_full(&src, &SaveOptions::default()).expect("writes").len(); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + let names = xobject_names(&part, 0); + assert!(names.iter().any(|n| n.as_slice() == b"ImA"), "the drawn image stays"); + assert!(!names.iter().any(|n| n.as_slice() == b"ImB"), "an image no page of the part draws must go"); + let part_bytes = write_full(&part, &SaveOptions::default()).expect("writes").len(); + assert!(part_bytes + 1000 < whole, "the part must not carry the other page's image: {part_bytes} vs {whole}"); +} + +#[test] +fn extract_keeps_the_union_when_the_part_has_both_pages() { + let part = full_roundtrip(&extract_pages(&doc_images(), &[0, 1]).unwrap()); + let names = xobject_names(&part, 0); + assert!(names.iter().any(|n| n.as_slice() == b"ImA")); + assert!(names.iter().any(|n| n.as_slice() == b"ImB")); +} + +/// Like `doc_images`, but the second page's content can't be decoded and the first page also +/// lists a font: pruning must not guess what the unreadable page draws. +fn doc_images_with(page2_content: &str, font: &str) -> Document { + 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] /Resources 7 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), + raw("/ImA Do"), + page2_content.into(), + format!("<< /XObject << /ImA 8 0 R /ImB 9 0 R >> {font} >>"), + image(1, 1, 1), + image(1, 1, 2000), + ]; + open(build(&b, "/Root 1 0 R")) +} + +#[test] +fn extract_keeps_images_an_undecodable_page_might_draw() { + let bogus = "<< /Length 7 /Filter /NoSuchFilter >>\nstream\n/ImB Do\nendstream"; + let part = full_roundtrip(&extract_pages(&doc_images_with(bogus, ""), &[0, 1]).unwrap()); + let names = xobject_names(&part, 0); + assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "an image the unreadable page may draw must stay"); +} + +#[test] +fn extract_keeps_images_type3_glyphs_may_draw() { + // A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`. + let font = "/Font << /T3 << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /CharProcs << >> /Encoding << /Differences [] >> /FirstChar 0 /LastChar 0 /Widths [0] >> >>"; + let part = full_roundtrip(&extract_pages(&doc_images_with(&raw("/ImB Do"), font), &[0]).unwrap()); + let names = xobject_names(&part, 0); + assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "pruning must not run through Type 3 resources"); +}