diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 8f943d41e..32915af6b 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -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, diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index e4976a81b..8cfa82674 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -41,73 +41,123 @@ pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { } } -/// 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, out: &mut HashMap) { - 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, + /// 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, + rewalks: usize, + /// False once a use may have been missed (depth or work limit). + complete: bool, + out: HashMap, +} + +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 { - 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). diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 4058a026d..72553f496 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -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); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dfcdb9aab..e270a8f21 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"