* 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:
parent
7bed2d393c
commit
dfb4e26e1a
|
|
@ -3694,6 +3694,7 @@ name = "pdfcraft-organize"
|
|||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ license.workspace = true
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue