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 <andrew.taylor@tayeng.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
taylorandrew07-lab
2026-10-10 16:21:07 -04:00
committed by GitHub
co-authored by Andrew Taylor Claude Opus 5.5
parent 4fd2c2b3a3
commit 4dafbfc325
7 changed files with 590 additions and 6 deletions
+1
View File
@@ -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::<Vec<_>>(),
}))
+1 -1
View File
@@ -1052,7 +1052,7 @@ pub fn tools() -> Vec<ToolDef> {
.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!({
+27
View File
@@ -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<u8> = (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);
}
+9 -3
View File
@@ -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<Report, Optim
optimize_with_progress(doc, settings, &mut |_| true)
}
/// [`optimize`], calling `progress` before each image and before the clean-up. Returning `false`
/// stops with [`OptimizeError::Cancelled`]; `doc` is then partly optimized and should be dropped.
/// [`optimize`], calling `progress` before each image and before the clean-up (and now and then
/// during a long one). Returning `false` stops with [`OptimizeError::Cancelled`]; `doc` is then
/// partly optimized and should be dropped.
pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: &mut dyn FnMut(Stage) -> bool) -> Result<Report, OptimizeError> {
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")) {
+304
View File
@@ -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<u32, usize>,
not_picture: HashSet<u32>,
}
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<u32, usize>) -> 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<u8> {
(*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<Vec<u8>>, longest: usize, found: &mut HashSet<Vec<u8>>) {
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<u8>),
/// 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<usize, OptimizeError> {
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<Vec<u8>> = 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<Vec<u8>> = 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<Vec<u8>> = 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)
}
+246
View File
@@ -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>) -> 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<Object>, 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<String> {
let res = doc.resolve(resources);
let mut n: Vec<String> =
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<String> {
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::<String>::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:?}");
}
}
+2 -2
View File
@@ -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"