mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
The effective-resolution walk skipped any form XObject already visited once when it was nested more than four deep, with one seen-set for the whole document. A later, larger use of the same form was never measured, so Reduce File Size and Optimize PDF resampled the image for its smallest use: a 1200 px image drawn 1 in and 8 in wide through six nested forms came out 150 px wide, about 19 ppi on the large copy. - Cycles are detected on the current path of forms, not document-wide. - A form with its own resources drawn again at the same scale (only moved) is skipped, as that can't change any image's resolution; forms without resources draw their caller's images and are always walked. - Repeat walks are capped at 100,000. If the cap or the 12-level depth limit leaves a use unmeasured, every image gets INFINITY: recompressed as the settings say, never resampled. Fixes #1811 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
9caddc6e9b
commit
3f25d55a96
@@ -20,7 +20,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet:
|
||||
side-by-side tiles, rotated strips, masks.
|
||||
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
|
||||
through form XObjects; the smallest over all uses). Colour and grayscale images above a
|
||||
through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be
|
||||
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
|
||||
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image.
|
||||
An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces,
|
||||
|
||||
+102
-52
@@ -41,73 +41,123 @@ pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one content stream, recording each image's displayed resolution (pixels per inch, the
|
||||
/// smaller of the two axes) and descending into form XObjects.
|
||||
fn walk(doc: &Document, data: &[u8], resources: &Dict, ctm: Matrix, depth: usize, seen: &mut HashSet<ObjRef>, out: &mut HashMap<ObjRef, f64>) {
|
||||
if depth > 12 {
|
||||
return;
|
||||
}
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut stack = vec![ctm];
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
let top = stack.last().copied().unwrap_or(ctm);
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(top),
|
||||
b"Q" if stack.len() > 1 => {
|
||||
stack.pop();
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = Matrix::from_operands(&op.operands)
|
||||
&& let Some(t) = stack.last_mut()
|
||||
{
|
||||
*t = m.then(&top);
|
||||
/// Forms nested deeper than this aren't followed.
|
||||
const MAX_FORM_DEPTH: usize = 12;
|
||||
/// Walks of a form already walked once (other scale or caller) before the measurement gives up.
|
||||
/// Each form's first walk is free: their number is bounded by the file's object count.
|
||||
const MAX_REWALKS: usize = 100_000;
|
||||
|
||||
/// State of one [`effective_resolutions`] pass.
|
||||
struct Walk<'a> {
|
||||
doc: &'a Document,
|
||||
/// The forms being walked right now, outermost first: a form drawing one of these is a cycle.
|
||||
path: Vec<ObjRef>,
|
||||
/// Forms with their own resources already walked, by the linear part of their matrix. Drawing
|
||||
/// one again at the same scale (only moved) can't change any image's resolution.
|
||||
walked: HashSet<(ObjRef, [u64; 4])>,
|
||||
/// Forms walked at least once.
|
||||
seen: HashSet<ObjRef>,
|
||||
rewalks: usize,
|
||||
/// False once a use may have been missed (depth or work limit).
|
||||
complete: bool,
|
||||
out: HashMap<ObjRef, f64>,
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
/// Walk one content stream, recording each image's displayed resolution (pixels per inch, the
|
||||
/// smaller of the two axes) and descending into form XObjects.
|
||||
fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) {
|
||||
let doc = self.doc;
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut stack = vec![ctm];
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
let top = stack.last().copied().unwrap_or(ctm);
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(top),
|
||||
b"Q" if stack.len() > 1 => {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
b"Do" => {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue };
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
match s.dict.name(b"Subtype") {
|
||||
Some(b"Image") => {
|
||||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64);
|
||||
let [a, b, c, d, ..] = top.0;
|
||||
let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt());
|
||||
if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 {
|
||||
let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0));
|
||||
let e = out.entry(r).or_insert(f64::INFINITY);
|
||||
*e = e.min(ppi);
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = Matrix::from_operands(&op.operands)
|
||||
&& let Some(t) = stack.last_mut()
|
||||
{
|
||||
*t = m.then(&top);
|
||||
}
|
||||
Some(b"Form") => {
|
||||
if !seen.insert(r) && depth > 4 {
|
||||
continue;
|
||||
}
|
||||
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten();
|
||||
let res =
|
||||
s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_else(|| resources.clone());
|
||||
let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&top);
|
||||
walk(doc, &s.decoded().unwrap_or_default(), &res, inner, depth + 1, seen, out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
b"Do" => {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue };
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
match s.dict.name(b"Subtype") {
|
||||
Some(b"Image") => {
|
||||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64);
|
||||
let [a, b, c, d, ..] = top.0;
|
||||
let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt());
|
||||
if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 {
|
||||
let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0));
|
||||
let e = self.out.entry(r).or_insert(f64::INFINITY);
|
||||
*e = e.min(ppi);
|
||||
}
|
||||
}
|
||||
Some(b"Form") => self.form(r, s, resources, top),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`.
|
||||
fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) {
|
||||
if self.path.contains(&r) {
|
||||
return;
|
||||
}
|
||||
if self.path.len() >= MAX_FORM_DEPTH {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
let doc = self.doc;
|
||||
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten();
|
||||
let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&ctm);
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
// A form without resources uses its caller's, so the same form can draw other images.
|
||||
if own.is_some() {
|
||||
let [a, b, c, d, ..] = inner.0;
|
||||
if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !self.seen.insert(r) {
|
||||
if self.rewalks >= MAX_REWALKS {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
self.rewalks += 1;
|
||||
}
|
||||
self.path.push(r);
|
||||
self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner);
|
||||
self.path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
|
||||
///
|
||||
/// If forms nest too deeply or are drawn too many times to measure every use, every image is
|
||||
/// given `INFINITY` (never resampled), since its largest use may be among those missed.
|
||||
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
|
||||
let mut out = HashMap::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() };
|
||||
for p in pages {
|
||||
let Some(d) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
let Some(contents) = d.get(b"Contents") else { continue };
|
||||
let res = page_resources(doc, *p).unwrap_or_default();
|
||||
walk(doc, &content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), 0, &mut seen, &mut out);
|
||||
walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
}
|
||||
out
|
||||
if !walk.complete {
|
||||
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
|
||||
}
|
||||
walk.out
|
||||
}
|
||||
|
||||
/// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3).
|
||||
|
||||
@@ -821,3 +821,110 @@ fn images_that_do_not_tile_a_picture_are_left_alone() {
|
||||
let (mut off, _) = printed_page(40, |_| false, |_, s| s);
|
||||
unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() });
|
||||
}
|
||||
|
||||
/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square.
|
||||
fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef {
|
||||
let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do");
|
||||
for _ in 1..levels {
|
||||
inner = form(doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do");
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeply_nested_form_is_measured_at_its_largest_use() {
|
||||
// 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled.
|
||||
let small = "q 72 0 0 72 36 36 cm /F Do Q";
|
||||
let large = "q 576 0 0 576 18 100 cm /F Do Q";
|
||||
for levels in [1, 6, 12] {
|
||||
for (pages, label) in [(vec![format!("{small} {large}")], "one page"), (vec![small.into(), large.into()], "two pages")] {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let outer = nested_forms(&mut doc, img, levels);
|
||||
for c in &pages {
|
||||
page(&mut doc, &[("F", outer)], c);
|
||||
}
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 150.0, "{levels} levels, {label}");
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200), "{levels} levels, {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_drawing_itself_is_walked_once_per_use() {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let f = form(&mut doc, None, "/Im Do q 2 0 0 2 0 0 cm /F Do Q");
|
||||
let mut x = xobjects(&[("Im", img)]);
|
||||
x.set(b"F".to_vec(), Object::Ref(f));
|
||||
let mut d = match &*doc.get(f) {
|
||||
Object::Stream(s) => s.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
d.dict.set(b"Resources".to_vec(), Object::Dict(resources(x)));
|
||||
doc.set(f, Object::Stream(d));
|
||||
page(&mut doc, &[("F", f)], "q 72 0 0 72 36 36 cm /F Do Q");
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
// Only the outer use: the cycle isn't followed, and doesn't count as a missed use.
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 1200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_form_without_resources_draws_each_callers_image() {
|
||||
// G has no /Resources, so its /Im is whichever image the form drawing it names.
|
||||
let mut doc = Document::new_empty();
|
||||
let a = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let b = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let g = form(&mut doc, None, "/Im Do");
|
||||
let mut outers = Vec::new();
|
||||
for img in [a, b] {
|
||||
let mut inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("Im", img), ("G", g)])))), "/G Do");
|
||||
for _ in 1..6 {
|
||||
inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do");
|
||||
}
|
||||
outers.push(inner);
|
||||
}
|
||||
page(&mut doc, &[("A", outers[0]), ("B", outers[1])], "q 72 0 0 72 36 36 cm /A Do Q q 576 0 0 576 18 100 cm /B Do Q");
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
let ppi = effective_resolutions(&doc, &refs);
|
||||
assert_eq!(ppi[&a].round(), 1200.0);
|
||||
assert_eq!(ppi.get(&b).map(|p| p.round()), Some(150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_are_not_resampled_when_some_uses_could_not_be_measured() {
|
||||
// Too deep: the 13th form isn't followed, so its use of the image is unknown.
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let other = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let deep = nested_forms(&mut doc, img, 13);
|
||||
page(
|
||||
&mut doc,
|
||||
&[("F", deep), ("Im", img), ("O", other)],
|
||||
"q 72 0 0 72 36 36 cm /Im Do Q q 72 0 0 72 200 36 cm /O Do Q q 576 0 0 576 18 100 cm /F Do Q",
|
||||
);
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
let ppi = effective_resolutions(&doc, &refs);
|
||||
assert_eq!((ppi[&img], ppi[&other]), (f64::INFINITY, f64::INFINITY));
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200));
|
||||
|
||||
// Too much work: forms without resources (so nothing can be skipped), each drawing the next
|
||||
// three times, twelve deep, would be (3^12 - 1) / 2 walks.
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let mut names = vec![("Im", img)];
|
||||
let mut next = "Im";
|
||||
let labels = ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"];
|
||||
for (i, label) in labels.iter().enumerate().rev() {
|
||||
let s = 1.0 + i as f64 / 100.0;
|
||||
let f = form(&mut doc, None, &format!("q {s} 0 0 {s} 0 0 cm /{next} Do Q /{next} Do q 1 0 0 1 1 0 cm /{next} Do Q"));
|
||||
names.push((label, f));
|
||||
next = label;
|
||||
}
|
||||
page(&mut doc, &names, "q 72 0 0 72 36 36 cm /F1 Do Q");
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
|
||||
}
|
||||
|
||||
@@ -7264,6 +7264,8 @@ commands = ["optimize.advanced"]
|
||||
tools = ["doc_optimize"]
|
||||
evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"]
|
||||
notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet"
|
||||
evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"]
|
||||
notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); monochrome not yet"
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.recompress-images"
|
||||
|
||||
Reference in New Issue
Block a user