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 <noreply@anthropic.com>

---------

Co-authored-by: Mohammed Munzir <Mohammed.Munzir@iga.gov.bh>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
munzir 2026-10-08 10:42:27 +03:00 committed by GitHub
parent 7bed2d393c
commit dfb4e26e1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 322 additions and 0 deletions

1
Cargo.lock generated
View File

@ -3694,6 +3694,7 @@ name = "pdfcraft-organize"
version = "0.2.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
"pdfcraft-cos",
"thiserror 2.0.21",
]

View File

@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
pdfcraft-content = { workspace = true }
pdfcraft-cos = { workspace = true }
thiserror = { workspace = true }

View File

@ -420,6 +420,9 @@ pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, Organi
crate::set_info(&mut out, key, &v)?;
}
}
// The pages share their source's resource dictionary, so the part would otherwise carry every
// XObject the source lists — images and all (#204). Keep only what these pages draw.
crate::prune::prune_unused_xobjects(&mut out)?;
Ok(out)
}

View File

@ -18,6 +18,7 @@ mod dedupe;
mod import;
mod labels;
mod outline;
mod prune;
pub mod view;
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};

View File

@ -0,0 +1,225 @@
//! Keeps only the XObjects a part's pages actually draw (Split / Extract Pages, #204).
//!
//! In a real book all pages share one `/Resources` dictionary, so every split part carried all
//! ~2,900 image XObjects while its 1,500 pages drew ~300 of them, and the part saved out at the
//! source's size. This pass follows each part's content streams (`Do`, form XObjects included)
//! and drops the `/XObject` entries no page names; the garbage-collecting rewrite on save then
//! drops the objects themselves.
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::OrganizeError;
/// Form XObjects nested deeper than this are a broken file, not a reason to hang.
const MAX_FORM_DEPTH: u8 = 8;
/// The dictionary a set of drawn names belongs to.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum XKey {
/// An indirect `/XObject` dictionary.
XObject(ObjRef),
/// An inline `/XObject` dictionary inside an indirect resources object.
Resources(ObjRef),
/// Everything inline on the page itself.
Page(ObjRef),
}
/// Who owns the resources currently being walked, for the key drawn names are recorded under.
#[derive(Clone, Copy)]
enum Owner {
/// An indirect resources object (a page's, or a form XObject's own).
Resources(ObjRef),
/// Resources inline on the page.
Page(ObjRef),
}
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.
unsure: HashSet<XKey>,
}
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<Vec<u8>> {
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<Vec<u8>>) -> usize {
let unused: Vec<Vec<u8>> = 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<Vec<u8>>) -> Result<usize, OrganizeError> {
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<usize, OrganizeError> {
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)
}

View File

@ -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<String> = 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<Vec<u8>> {
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<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] /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");
}