From 4dafbfc32574b2b1218dc80d88224d8bf44090cb Mon Sep 17 00:00:00 2001 From: taylorandrew07-lab Date: Sat, 10 Oct 2026 16:21:07 -0400 Subject: [PATCH] M11: Reduce file size and Optimize PDF drop images nothing draws, such as a deleted image (#894) Deleting an image with Edit a PDF removes its Do operation, but the image stays in the page's /Resources /XObject, so every full rewrite (Reduce file size, Optimize PDF) carried its data along: a page left blank still weighed the whole picture. Clean Up now drops image and form XObjects from a page's own resources when their name is written in no stream the document still uses. Shared and inherited resources are left alone, and if any stream can't be read the way a viewer would read it, nothing is removed: viewers look names up through parent resources, so only a name written nowhere is safe to drop. doc_optimize reports unused_xobjects. Co-authored-by: Andrew Taylor Co-authored-by: Claude Opus 5.5 --- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 27 +++ crates/optimize/src/lib.rs | 12 +- crates/optimize/src/unused.rs | 304 ++++++++++++++++++++++++++ crates/optimize/tests/optimize.rs | 246 +++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 crates/optimize/src/unused.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d15652236..f19215c13 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -855,6 +855,7 @@ impl Automation { "invalid_links": o.invalid_links, "invalid_bookmarks": o.invalid_bookmarks, "unreferenced_dests": o.unreferenced_dests, + "unused_xobjects": o.unused_xobjects, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), })) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f7a4d5bca..4b29b37ae 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1052,7 +1052,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 7a201c940..79dd29bd3 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3684,3 +3684,30 @@ fn command_batch_refuses_more_than_a_thousand_steps() { let err = a.call("command_batch", &json!({"steps": steps})).unwrap_err(); assert!(err.to_string().contains("at most 1000 steps"), "{err}"); } + +#[test] +fn a_deleted_image_is_not_kept_by_reduce_or_optimize() { + let dir = workdir("deleted-image"); + let (w, h) = (600u32, 400u32); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8 ^ (i & 7) as u8, (i / w * 255 / h) as u8, 128]).collect(); + let mut photo = Vec::new(); + { + let mut enc = png::Encoder::new(&mut photo, w, h); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + std::fs::write(dir.join("photo.png"), photo).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.png"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "delete" })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }))["count"], 0); + // The page is blank, so the copies are tiny: the picture's data is not carried along. + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf" })); + assert_eq!(r["unused_xobjects"], 1, "{r}"); + assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}"); + let r = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); + assert!(r["bytes_after"].as_u64().unwrap() < 4_000, "{r}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "reduced.pdf" })); + assert_eq!(reopened["pages"], 1); +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 1d384ff51..5ebd006cb 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -9,7 +9,8 @@ //! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. -//! - **Clean up:** Flate-compress streams that have no filter. +//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted +//! image); Flate-compress streams that have no filter. //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine @@ -20,6 +21,7 @@ mod audit; mod images; mod links; +mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; @@ -125,6 +127,8 @@ pub struct Report { pub invalid_links: usize, pub invalid_bookmarks: usize, pub unreferenced_dests: usize, + /// Images and forms the pages listed but never drew, dropped (Clean Up). + pub unused_xobjects: usize, } /// Optimize `doc` in place. @@ -132,8 +136,9 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; @@ -141,6 +146,7 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: if !progress(Stage::CleanUp) { return Err(OptimizeError::Cancelled); } + report.unused_xobjects = unused::remove_unused(doc, &pages, progress)?; if settings.discard_thumbnails { for p in &pages { if doc.get(*p).as_dict().is_some_and(|d| d.contains(b"Thumb")) { diff --git a/crates/optimize/src/unused.rs b/crates/optimize/src/unused.rs new file mode 100644 index 000000000..b8c8a62b2 --- /dev/null +++ b/crates/optimize/src/unused.rs @@ -0,0 +1,304 @@ +//! Clean Up: drop image and form XObjects that a page lists in its resources but nothing draws, +//! such as an image deleted with Edit a PDF (its `Do` goes, its resource entry stays). A full +//! rewrite keeps every object that is still referenced, so without this the image would stay in +//! the file. +//! +//! Removing a name something still draws would make content disappear, so the rule is simple +//! and errs on the side of keeping: +//! - only a page's own resources are cleaned: dictionaries nothing else refers to (resources +//! shared with other pages, forms or appearances, and inherited ones, are left alone); +//! - a name goes only if it is written nowhere in any stream the document still uses, found by +//! scanning the bytes wherever the name appears. Nothing can draw a resource by name without +//! writing the name in a stream, whichever resources a viewer then looks it up in; +//! - if any such stream can't be read exactly the way a viewer would read it, nothing is +//! removed. Only pictures (images in an image codec, used only as images) are skipped. +//! +//! So a name another page writes for its own image keeps this page's image of that name too. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::{OptimizeError, Stage}; + +/// Image codecs: a stream encoded with one holds picture data. +const IMAGE_CODECS: [&[u8]; 6] = [b"DCTDecode", b"DCT", b"JPXDecode", b"JBIG2Decode", b"CCITTFaxDecode", b"CCF"]; + +/// Most data decoded while looking for names, in all; past it nothing is removed. +const DECODE_BUDGET: usize = 1 << 31; + +/// How much data is decoded between two checks for a cancel. +const CANCEL_CHECK: usize = 64 << 20; + +/// Where a page's own `/XObject` dictionary is. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Place { + /// An object of its own (`/XObject n 0 R`). + Own(ObjRef), + /// Directly in the page's resources object (`/Resources n 0 R`). + InResources(ObjRef), + /// Directly in resources written directly in the page. + InPage(ObjRef), +} + +/// How an object is used where a reference to it appears. +#[derive(Clone, Copy, PartialEq)] +enum Use { + /// As a picture: an `/XObject` entry, `/SMask`, `/Mask`, `/Thumb` or an alternate `/Image`. + Picture, + /// As an `/XObject` dictionary (its entries are pictures or forms). + Entries, + Other, +} + +/// The objects the document uses (everything a full rewrite keeps): how many references point +/// at each, and which are referenced other than as a picture. +struct Graph { + refs: HashMap, + not_picture: HashSet, +} + +fn graph(doc: &Document) -> Graph { + let child = |key: &[u8], parent: Use| match (parent, key) { + (Use::Entries, _) => Use::Picture, + (_, b"XObject") => Use::Entries, + (_, b"SMask" | b"Mask" | b"Thumb" | b"Image") => Use::Picture, + _ => Use::Other, + }; + let mut g = Graph { refs: HashMap::new(), not_picture: HashSet::new() }; + let mut stack: Vec<(Object, Use)> = doc.trailer().iter().map(|(_, v)| (v.clone(), Use::Other)).collect(); + while let Some((obj, how)) = stack.pop() { + let expand = |d: &Dict, stack: &mut Vec<(Object, Use)>| stack.extend(d.iter().map(|(k, v)| (v.clone(), child(k, how)))); + match obj { + Object::Ref(r) => { + if how != Use::Picture { + g.not_picture.insert(r.num); + } + let n = g.refs.entry(r.num).or_default(); + *n += 1; + if *n > 1 { + continue; + } + match &*doc.get(r) { + Object::Dict(d) => expand(d, &mut stack), + Object::Stream(s) => expand(&s.dict, &mut stack), + Object::Array(a) => stack.extend(a.iter().map(|v| (v.clone(), how))), + _ => {} + } + } + Object::Dict(d) => expand(&d, &mut stack), + Object::Array(a) => stack.extend(a.into_iter().map(|v| (v, how))), + _ => {} + } + } + g +} + +/// `page`'s own `/XObject` dictionary, if nothing else refers to it. +fn own_xobjects(doc: &Document, page: ObjRef, refs: &HashMap) -> Option<(Place, Dict)> { + let only_once = |r: &ObjRef| refs.get(&r.num) == Some(&1); + let p = doc.get(page); + let p = p.as_dict()?; + // A node with kids passes its resources on. + if p.name(b"Type") != Some(b"Page") || p.contains(b"Kids") { + return None; + } + let (res, place) = match p.get(b"Resources")? { + Object::Ref(r) if only_once(r) => (doc.get(*r).as_dict()?.clone(), Place::InResources(*r)), + Object::Dict(d) => (d.clone(), Place::InPage(page)), + _ => return None, + }; + match res.get(b"XObject")? { + Object::Ref(x) if only_once(x) => Some((Place::Own(*x), doc.get(*x).as_dict()?.clone())), + Object::Dict(x) => Some((place, x.clone())), + _ => None, + } +} + +fn hex(b: &u8) -> Option { + (*b as char).to_digit(16).map(|d| d as u8) +} + +/// Record which of `wanted` (none longer than `longest`) are written in `data` (`/Name`, `#xx` +/// escapes decoded), wherever they appear: operands, strings, comments or inline image data. +fn find_names(data: &[u8], wanted: &HashSet>, longest: usize, found: &mut HashSet>) { + let regular = + |b: u8| !matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'); + let mut i = 0; + while let Some(slash) = data.get(i..).and_then(|rest| rest.iter().position(|&b| b == b'/')) { + let mut j = i + slash + 1; + let mut name = Vec::new(); + let mut too_long = false; + while let Some(&b) = data.get(j) { + if !regular(b) { + break; + } + let (byte, step) = match (b, data.get(j + 1).and_then(hex), data.get(j + 2).and_then(hex)) { + (b'#', Some(h), Some(l)) => (h << 4 | l, 3), + _ => (b, 1), + }; + if name.len() < longest { + name.push(byte); + } else { + too_long = true; + } + j += step; + } + if !too_long && wanted.contains(&name) { + found.insert(name); + } + i = j; + } +} + +/// What a stream's data is, for finding names in it. +enum Data { + /// A picture: image data in an image codec, used only as an image. + Picture, + Decoded(Vec), + /// Not readable exactly the way a viewer would read it. + Unknown, +} + +/// Whether the document's security handler leaves metadata streams unencrypted. +fn plain_metadata(doc: &Document) -> bool { + doc.trailer() + .get(b"Encrypt") + .map(|e| doc.resolve(e)) + .is_some_and(|e| matches!(e.as_dict().and_then(|e| e.get(b"EncryptMetadata")), Some(Object::Bool(false)))) +} + +fn data(s: &Stream, num: u32, g: &Graph, encrypted: bool, plain_metadata: bool) -> Data { + let d = &s.dict; + // Streams the security handler leaves as stored, which a viewer may still decrypt. + let ty = d.name(b"Type"); + if encrypted && (ty == Some(b"XRef") || (ty == Some(b"Metadata") && plain_metadata)) { + return Data::Unknown; + } + // External data, or a filter description that is abbreviated, indirect or doesn't line up: + // a viewer might decode it differently from this check. + if d.contains(b"F") + || d.contains(b"DP") + || matches!(d.get(b"Filter"), Some(Object::Ref(_))) + || matches!(d.get(b"DecodeParms"), Some(Object::Ref(_))) + { + return Data::Unknown; + } + let filters: Vec<&[u8]> = match d.get(b"Filter") { + None => Vec::new(), + Some(Object::Name(n)) => vec![n.as_slice()], + Some(Object::Array(a)) => { + let names: Vec<&[u8]> = a.iter().filter_map(Object::as_name).collect(); + if names.len() != a.len() { + return Data::Unknown; + } + names + } + Some(_) => return Data::Unknown, + }; + if filters.contains(&&b"Crypt"[..]) { + return Data::Unknown; + } + if filters.iter().any(|f| IMAGE_CODECS.contains(f)) { + // Viewers decode any stream's codec, so picture data skipped here must be a picture. + let picture = d.name(b"Subtype") == Some(b"Image") && !g.not_picture.contains(&num); + return if picture { Data::Picture } else { Data::Unknown }; + } + // Parameters only as direct integers, which this check and viewers read alike. + let direct = |p: &Object| match p { + Object::Dict(p) => p.iter().all(|(_, v)| matches!(v, Object::Int(_))), + Object::Null => true, + _ => false, + }; + let parms_line_up = match d.get(b"DecodeParms") { + None => true, + Some(p @ (Object::Dict(_) | Object::Null)) => filters.len() == 1 && direct(p), + Some(Object::Array(a)) => a.len() == filters.len() && a.iter().all(direct), + Some(_) => false, + }; + if !parms_line_up { + return Data::Unknown; + } + s.decoded_strict().map_or(Data::Unknown, Data::Decoded) +} + +/// Drop the image and form XObjects that `pages` list in their own resources but no stream +/// names. Returns how many went. `progress` is asked again every [`CANCEL_CHECK`] bytes +/// decoded; `false` cancels. +pub(crate) fn remove_unused(doc: &mut Document, pages: &[ObjRef], progress: &mut dyn FnMut(Stage) -> bool) -> Result { + let g = graph(doc); + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + for page in pages { + let Some((place, xobjects)) = own_xobjects(doc, *page, &g.refs) else { continue }; + if !seen.insert(place) { + continue; + } + let names: Vec> = xobjects + .iter() + .filter(|(_, v)| matches!(&*doc.resolve(v), Object::Stream(s) if matches!(s.dict.name(b"Subtype"), Some(b"Image" | b"Form")))) + .map(|(name, _)| name.clone()) + .collect(); + if !names.is_empty() { + candidates.push((place, names)); + } + } + let wanted: HashSet> = candidates.iter().flat_map(|(_, names)| names.iter().cloned()).collect(); + let longest = wanted.iter().map(Vec::len).max().unwrap_or(0); + let (encrypted, plain_metadata) = (doc.trailer().contains(b"Encrypt"), plain_metadata(doc)); + let mut written = HashSet::new(); + let (mut decoded, mut next_check) = (0usize, CANCEL_CHECK); + for num in g.refs.keys() { + if written.len() == wanted.len() { + return Ok(0); + } + let obj = doc.get(ObjRef::new(*num, doc.generation(*num))); + let Object::Stream(s) = &*obj else { continue }; + match data(s, *num, &g, encrypted, plain_metadata) { + Data::Picture => {} + Data::Decoded(bytes) => { + decoded = decoded.saturating_add(bytes.len()); + if decoded > DECODE_BUDGET { + return Ok(0); + } + if decoded >= next_check { + next_check = decoded.saturating_add(CANCEL_CHECK); + if !progress(Stage::CleanUp) { + return Err(OptimizeError::Cancelled); + } + } + find_names(&bytes, &wanted, longest, &mut written); + } + Data::Unknown => return Ok(0), + } + } + let mut removed = 0; + for (place, names) in candidates { + let gone: Vec> = names.into_iter().filter(|n| !written.contains(n)).collect(); + if gone.is_empty() { + continue; + } + let strip = |x: &mut Dict| { + for n in &gone { + x.remove(n); + } + }; + match place { + Place::Own(x) => doc.update_dict(x, strip)?, + Place::InResources(r) => doc.update_dict(r, |res| { + if let Some(Object::Dict(x)) = res.get_mut(b"XObject") { + strip(x); + } + })?, + Place::InPage(p) => doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") + && let Some(Object::Dict(x)) = res.get_mut(b"XObject") + { + strip(x); + } + })?, + } + removed += gone.len(); + } + Ok(removed) +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 007e859f5..ff4dc801a 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -337,3 +337,249 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() { assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}"); assert_eq!(calls, 2, "nothing runs after the refusal"); } + +/// A page with `resources` and, if given, `contents`. +fn raw_page(doc: &mut Document, resources: Object, contents: Option) -> ObjRef { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), resources); + if let Some(c) = contents { + p.set(b"Contents".to_vec(), Object::Ref(c)); + } + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + r +} + +/// Builders for the tests below. +fn xobjects(pairs: &[(&str, ObjRef)]) -> Dict { + let mut x = Dict::new(); + for (n, r) in pairs { + x.set(n.as_bytes().to_vec(), Object::Ref(*r)); + } + x +} + +fn resources(x: Dict) -> Dict { + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + res +} + +fn content(doc: &mut Document, c: &str) -> ObjRef { + doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))) +} + +fn form(doc: &mut Document, resources: Option, c: &str) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()])); + if let Some(r) = resources { + d.set(b"Resources".to_vec(), r); + } + doc.add(Object::Stream(Stream::flate(d, c.as_bytes()))) +} + +fn xobject_names(doc: &Document, resources: &Object) -> Vec { + let res = doc.resolve(resources); + let mut n: Vec = + res.as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(); + n.sort(); + n +} + +fn page_xobjects(doc: &Document, page: ObjRef) -> Vec { + let res = doc.get(page).as_dict().unwrap().get(b"Resources").unwrap().clone(); + xobject_names(doc, &res) +} + +fn count_images(bytes: &[u8]) -> usize { + let doc = Document::open(Arc::new(bytes.to_vec())).unwrap(); + doc.object_numbers() + .into_iter() + .filter(|n| matches!(&*doc.get(ObjRef::new(*n, doc.generation(*n))), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))) + .count() +} + +#[test] +fn images_and_forms_nothing_draws_are_dropped() { + let mut doc = Document::new_empty(); + let img = |doc: &mut Document| image(doc, 64, 64, 3, false, None); + + // Edit a PDF deleting an image: the page gets a new content stream without the `Do`; the + // old one, which still names the image, is no longer used. + let t1 = img(&mut doc); + let old = content(&mut doc, "q 72 0 0 72 0 0 cm /ImT Do Q"); + let t = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImT", t1)]))), Some(old)); + let new = content(&mut doc, ""); + doc.update_dict(t, |p| p.set(b"Contents".to_vec(), Object::Ref(new))).unwrap(); + // Dropped: a JPEG a page no longer draws, beside one it does. + let a1 = img(&mut doc); + let a2 = image(&mut doc, 64, 64, 3, true, None); + let c = content(&mut doc, "q 72 0 0 72 0 0 cm /Im1 Do Q"); + let a = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", a1), ("Im2", a2)]))), Some(c)); + // Dropped from resources the page alone refers to, written as an object of their own. + let s1 = img(&mut doc); + let own = doc.add(Object::Dict(resources(xobjects(&[("Unused", s1)])))); + let c = content(&mut doc, ""); + let s = raw_page(&mut doc, Object::Ref(own), Some(c)); + // An image drawn only inside a form stays; one beside it goes. + let (d3, d4) = (img(&mut doc), img(&mut doc)); + let f = form(&mut doc, None, "/Im3 Do"); + let c = content(&mut doc, "/F Do"); + let d = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("F", f), ("Im3", d3), ("Im4", d4)]))), Some(c)); + + // Kept, though the page's own content doesn't name them: + // - drawn by a form with resources of its own that lack the name (renderers fall back to + // the page's); + let k1 = img(&mut doc); + let mut procset = Dict::new(); + procset.set(b"ProcSet".to_vec(), Object::Array(vec![Object::name("PDF")])); + let fk = form(&mut doc, Some(Object::Dict(procset)), "/ImK Do"); + let c = content(&mut doc, "/Fk Do"); + let k = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Fk", fk), ("ImK", k1)]))), Some(c)); + // - drawn by a Type 3 glyph named like an inert key; + let g6 = img(&mut doc); + let glyph = content(&mut doc, "/Im6 Do"); + let mut procs = Dict::new(); + procs.set(b"P".to_vec(), Object::Ref(glyph)); + let mut t3 = Dict::new(); + t3.set(b"Type".to_vec(), Object::name("Font")); + t3.set(b"Subtype".to_vec(), Object::name("Type3")); + t3.set(b"CharProcs".to_vec(), Object::Dict(procs)); + let mut fonts = Dict::new(); + fonts.set(b"T3".to_vec(), Object::Dict(t3)); + let mut res = resources(xobjects(&[("Im6", g6)])); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let c = content(&mut doc, "BT /T3 1 Tf (P) Tj ET"); + let g = raw_page(&mut doc, Object::Dict(res), Some(c)); + // - after an inline image whose data runs into the operators (no space before `EI`), or + // given as the second of two operands; + let (l1, l2) = (img(&mut doc), img(&mut doc)); + let c = content(&mut doc, "BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 00>EI Q q /ImL Do Q /Other /ImL2 Do"); + let l = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("ImL", l1), ("ImL2", l2)]))), Some(c)); + // - drawn by a form field that is on no page; + let z1 = img(&mut doc); + let c = content(&mut doc, ""); + let z = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Z1", z1)]))), Some(c)); + let wap = form(&mut doc, None, "/Z1 Do"); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(wap)); + let mut widget = Dict::new(); + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + widget.set(b"AP".to_vec(), Object::Dict(appearance)); + let widget = doc.add(Object::Dict(widget)); + let mut acroform = Dict::new(); + acroform.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(widget)])); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acroform))).unwrap(); + // - in resources shared by pages (or with an annotation's appearance), or inherited from the + // page tree: left alone; + let (b1, b2) = (img(&mut doc), img(&mut doc)); + let shared = doc.add(Object::Dict(resources(xobjects(&[("X1", b1), ("X2", b2)])))); + let c = content(&mut doc, "0 0 m"); + raw_page(&mut doc, Object::Ref(shared), Some(c)); + let c = content(&mut doc, "/X1 Do"); + raw_page(&mut doc, Object::Ref(shared), Some(c)); + let (inh, unused_inh) = (img(&mut doc), img(&mut doc)); + let tree = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + doc.update_dict(tree, |d| d.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Inh", inh), ("Old", unused_inh)]))))).unwrap(); + // - named by another page for its own image. + let (r1, r2) = (img(&mut doc), img(&mut doc)); + let c = content(&mut doc, ""); + let r = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r1)]))), Some(c)); + let c = content(&mut doc, "/Im9 Do"); + raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im9", r2)]))), Some(c)); + + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.unused_xobjects, 4, "{report:?}"); + assert!(page_xobjects(&doc, t).is_empty(), "the deleted image goes"); + assert_eq!(page_xobjects(&doc, a), ["Im1"]); + assert!(xobject_names(&doc, &Object::Ref(own)).is_empty()); + assert!(page_xobjects(&doc, s).is_empty()); + assert_eq!(page_xobjects(&doc, d), ["F", "Im3"]); + assert_eq!(page_xobjects(&doc, k), ["Fk", "ImK"]); + assert_eq!(page_xobjects(&doc, g), ["Im6"]); + assert_eq!(page_xobjects(&doc, l), ["ImL", "ImL2"]); + assert_eq!(page_xobjects(&doc, z), ["Z1"]); + assert_eq!(xobject_names(&doc, &Object::Ref(shared)), ["X1", "X2"]); + assert_eq!(page_xobjects(&doc, tree), ["Inh", "Old"]); + assert_eq!(page_xobjects(&doc, r), ["Im9"]); + let after = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_eq!((count_images(&before), count_images(&after)), (17, 13), "the dropped images are gone from the file"); +} + +#[test] +fn nothing_is_dropped_when_a_stream_cannot_be_checked() { + // A page with an image nothing draws, and an appearance stream on it. + let build = |stream: Stream| { + let mut doc = Document::new_empty(); + let unused = image(&mut doc, 64, 64, 3, false, None); + let c = content(&mut doc, ""); + let page = raw_page(&mut doc, Object::Dict(resources(xobjects(&[("Im1", unused)]))), Some(c)); + let other = doc.add(Object::Stream(stream)); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(other)); + let mut annot = Dict::new(); + annot.set(b"AP".to_vec(), Object::Dict(appearance)); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annot)]))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + (report.unused_xobjects, page_xobjects(&doc, page)) + }; + let (gone, kept) = ((1, Vec::::new()), (0, vec!["Im1".to_string()])); + // Valid compressed data with extra keys. + let flate = |extra: &[(&str, Object)]| { + let mut s = Stream::flate(Dict::new(), b"0 0 m"); + s.dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + for (k, v) in extra { + s.dict.set(k.as_bytes().to_vec(), v.clone()); + } + s + }; + let parms = |k: &str, v: Object| { + let mut p = Dict::new(); + p.set(k.as_bytes().to_vec(), v); + Object::Dict(p) + }; + // Readable: the image goes. + assert_eq!(build(flate(&[])), gone); + assert_eq!(build(flate(&[("DecodeParms", parms("Predictor", Object::Int(1)))])), gone); + // The name written with an escape still counts. + assert_eq!(build(Stream::flate(Dict::new(), b"/Im#31 Do")), kept); + // Read differently by viewers, or not at all: nothing goes. + let dangling = Object::Ref(ObjRef::new(999, 0)); + let cases = [ + Stream::from_raw(flate(&[]).dict, b"not zlib data".to_vec()), + flate(&[("Filter", dangling.clone())]), + flate(&[("Filter", Object::Array(vec![Object::name("FlateDecode"), dangling.clone()]))]), + flate(&[("Filter", Object::Array(vec![Object::name("Crypt"), Object::name("FlateDecode")]))]), + flate(&[("DP", parms("Predictor", Object::Int(1)))]), + flate(&[("DecodeParms", Object::Array(vec![Object::Dict(Dict::new()), Object::Dict(Dict::new())]))]), + flate(&[("DecodeParms", parms("Predictor", dangling))]), + flate(&[("DecodeParms", parms("Predictor", Object::Real(12.5)))]), + flate(&[("F", Object::name("external.dat"))]), + // An image codec on a stream drawn as an appearance: viewers decode it as content. + Stream::from_raw( + { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d + }, + vec![0; 8], + ), + ]; + for s in cases { + let dict = s.dict.clone(); + assert_eq!(build(s), kept, "{dict:?}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4b36bea82..b7690cb8b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7154,8 +7154,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled"] -notes = "Images, Discard Objects, Discard User Data, Clean Up; runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no space audit" +evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"] +notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no space audit" [[feature]] id = "optimize.downsample-images"