mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
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:
co-authored by
Andrew Taylor
Claude Opus 5.5
parent
4fd2c2b3a3
commit
4dafbfc325
@@ -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<_>>(),
|
||||
}))
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,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")) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user