M11: merge image fragments, and the Standard and Mobile optimizer presets (closes #1783) (#1784)

* M11: merge image fragments, and the Standard and Mobile optimizer presets

Some PDF printers draw a page picture as thousands of images one pixel row
high (one real page: 3,300 strips, 4.3 MB). The optimizer works image by
image, a one-row image can't be resampled, and every JPEG strip carries its
own headers, so Reduce File Size saved 2 bytes on it.

- pdfcraft-optimize `fragments` (Settings::merge_fragments, on by default):
  runs of `q … cm /Im Do Q` drawing upright 8-bit grey or RGB images in one
  colour space, sharing their left and right edges and stacked edge to edge
  (at least four, nothing else drawn between them; or, failing that, the
  rows drawn one under the other up to a gap) become one lossless image at
  their finest resolution. The image settings then resample it, and Clean
  Up drops the strips. A page whose content can't be read back exactly is
  left alone.
- Presets as in Acrobat's Settings menu: Standard (Reduce File Size's
  choices) and Mobile (images above 144 ppi to 96 ppi, JPEG high). The PDF
  Optimizer dialog has a Settings menu (Custom once an option changes) and
  a Merge image fragments box; doc_optimize takes preset and
  merge_fragments and reports merged_images and fragments_merged.
- A progress stage for the merge; four new strings in all 21 catalogs.

On the real page: Standard/Reduce 4.3 MB → 231 KB, Mobile → 161 KB, with
a rendering closer to the original than Acrobat's Mobile result (147 KB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refresh catalog hashes after merging main

---------

Co-authored-by: magdale76 <16279778+magdale76@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
magdale76
2026-10-15 02:25:19 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 magdale76 Brandon Thomas
parent 72a46facb8
commit df0bc0ffbc
34 changed files with 803 additions and 36 deletions
+299
View File
@@ -0,0 +1,299 @@
//! Merge image fragments: some PDF printers draw a page picture as hundreds or thousands of
//! images one pixel row high, stacked edge to edge. Each fragment is too small to resample, and
//! every JPEG fragment carries its own headers, so a one-page document can weigh megabytes and
//! no image setting makes it smaller. Here the fragments are put back together into one image,
//! which the image settings then resample and recompress like any other.
//!
//! What counts as a run of fragments, and is merged:
//! - consecutive `q … cm /Name Do Q` groups in a page's content stream, with nothing else
//! between them, each drawing an image upright (no rotation, skew or flip);
//! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array
//! that changes anything, in a codec PdfCraft decodes;
//! - they share their left and right edges and are stacked edge to edge with no gap or overlap
//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them.
//!
//! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost
//! until the image settings downsample it; it is stored losslessly. A page whose content
//! stream can't be read back exactly is left alone. The fragments are then unused, and Clean Up
//! drops them.
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample};
use crate::{OptimizeError, Report, Stage};
/// Fewest stacked images treated as fragments of one picture.
pub const MIN_FRAGMENTS: usize = 4;
/// Most pixels a merged image may have (memory).
const MAX_PIXELS: u64 = 80_000_000;
/// One fragment: its image, its box in the coordinates the run is drawn in, and its size.
struct Fragment {
image: ObjRef,
rect: [f64; 4],
width: u32,
height: u32,
}
/// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix
/// and the name drawn.
fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec<u8>)> {
if !ops.get(i)?.is("q") {
return None;
}
let mut j = i + 1;
let mut m = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
while let Some(op) = ops.get(j).filter(|o| o.is("cm")) {
m = Matrix::from_operands(&op.operands)?.then(&m);
j += 1;
}
let draw = ops.get(j).filter(|o| o.is("Do"))?;
let name = draw.name(0)?.to_vec();
ops.get(j + 1).filter(|o| o.is("Q"))?;
(j > i + 1).then_some((j + 2, m, name))
}
/// The image `name` draws, if it can be a fragment: its reference, size, colour space and
/// component count.
fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRef, u32, u32, Object, usize)> {
let r = xobjects.get(name).and_then(Object::as_ref)?;
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { return None };
let d = &s.dict;
if d.name(b"Subtype") != Some(b"Image") || d.int(b"BitsPerComponent") != Some(8) || d.contains(b"Mask") || d.contains(b"SMask") {
return None;
}
if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) || !decodable(doc, s)? {
return None;
}
let cs = d.get(b"ColorSpace")?.clone();
let n = components(doc, &cs)?;
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
(w > 0 && h > 0).then_some((r, w, h, cs, n))
}
/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge.
/// Sorts it from the top down.
fn stacked(run: &mut [Fragment]) -> bool {
let Some(first) = run.first() else { return false };
let [x0, _, x1, _] = first.rect;
let width = x1 - x0;
if !(width.is_finite() && width > 0.0) {
return false;
}
let tol = 1e-3 * width.max(1.0);
if run.iter().any(|f| {
(f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater)
}) {
return false;
}
run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3]));
run.windows(2).all(|w| match w {
[a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]),
_ => true,
})
}
/// The fragment groups from `i` on while each is drawn right under (or right over) the one
/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump.
fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec<Fragment> {
let mut run: Vec<Fragment> = Vec::new();
let mut down: Option<bool> = None;
let mut j = i;
while let Some((next, m, name)) = group_at(ops, j) {
let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break };
let [a, _, _, d, e, f] = m.0;
let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height };
if let Some(prev) = run.last() {
let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]);
let below = (prev.rect[1] - f2.rect[3]).abs() <= tol;
let above = (f2.rect[1] - prev.rect[3]).abs() <= tol;
let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0);
match (down, below, above) {
_ if !same_x => break,
(None | Some(true), true, _) => down = Some(true),
(None | Some(false), _, true) => down = Some(false),
_ => break,
}
}
run.push(f2);
j = next;
}
run
}
/// The op index after the first `count` fragment groups from `i`.
fn run_end(ops: &[Op], i: usize, count: usize) -> usize {
let mut j = i;
for _ in 0..count {
match group_at(ops, j) {
Some((next, ..)) => j = next,
None => break,
}
}
j
}
/// The run's pixels as one image, top row first: `(pixels, width, height)`.
fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec<u8>, u32, u32)> {
let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]);
let width = run.iter().map(|f| f.width).max()?;
// Pixel rows per unit of height: the finest fragment's.
let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max);
let height = ((top - bottom) * density).round();
if !(height.is_finite() && height >= 1.0) || (width as u64) * (height as u64) > MAX_PIXELS {
return None;
}
let height = height as u32;
let row = width as usize * n;
let mut out = vec![255u8; row * height as usize];
for f in run {
let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height);
let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height);
if r1 <= r0 {
continue;
}
let obj = doc.get(f.image);
let Object::Stream(s) = &*obj else { return None };
let px = pixels(s, n)?;
let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? };
out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?);
}
Some((out, width, height))
}
/// A name for the merged image that `xobjects` doesn't use yet.
fn fresh_name(xobjects: &Dict, k: &mut usize) -> Vec<u8> {
loop {
*k += 1;
let name = format!("PCMerged{k}").into_bytes();
if !xobjects.contains(&name) {
return name;
}
}
}
/// Add `name` → `image` to the page's image resources, wherever they live.
fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> Result<(), OptimizeError> {
let res = doc.get(page).as_dict().and_then(|d| d.get(b"Resources").cloned());
let put = |d: &mut Dict| {
let mut x = d.get(b"XObject").and_then(Object::as_dict).cloned().unwrap_or_default();
x.set(name.to_vec(), Object::Ref(image));
d.set(b"XObject".to_vec(), Object::Dict(x));
};
match res {
Some(Object::Ref(r)) => match doc.get(r).as_dict().and_then(|d| d.reference(b"XObject")) {
Some(x) => doc.update_dict(x, |d| d.set(name.to_vec(), Object::Ref(image)))?,
None => doc.update_dict(r, put)?,
},
Some(Object::Dict(d)) => match d.reference(b"XObject") {
Some(x) => doc.update_dict(x, |d| d.set(name.to_vec(), Object::Ref(image)))?,
None => doc.update_dict(page, |p| {
let mut d = d.clone();
put(&mut d);
p.set(b"Resources".to_vec(), Object::Dict(d));
})?,
},
// Inherited (or none): the page gets its own copy with the new image.
_ => {
let mut d = page_resources(doc, page).unwrap_or_default();
if let Some(x) = d.reference(b"XObject") {
let x = doc.get(x).as_dict().cloned().unwrap_or_default();
d.set(b"XObject".to_vec(), Object::Dict(x));
}
put(&mut d);
doc.update_dict(page, |p| p.set(b"Resources".to_vec(), Object::Dict(d)))?;
}
}
Ok(())
}
/// Merge the fragment runs of one page; returns how many fragments were merged.
fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<usize, OptimizeError> {
let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) };
let parsed = parse(&content_bytes(doc, &contents));
if parsed.skipped > 0 {
return Ok(0);
}
let resources = page_resources(doc, page).unwrap_or_default();
let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let ops = parsed.ops;
let mut out: Vec<Op> = Vec::with_capacity(ops.len());
let (mut i, mut merged, mut k) = (0, 0, 0);
while i < ops.len() {
// The longest run of fragment groups from here with one colour space.
let mut run = Vec::new();
let mut kind: Option<(Object, usize)> = None;
let mut j = i;
while let Some((next, m, name)) = group_at(&ops, j) {
let Some((image, width, height, cs, n)) = fragment_image(doc, &xobjects, &name) else { break };
let [a, b, c, d, e, f] = m.0;
if b.abs() > 1e-9 || c.abs() > 1e-9 || !(a > 0.0 && d > 0.0) {
break;
}
match &kind {
Some((k_cs, k_n)) if *k_cs != cs || *k_n != n => break,
None => kind = Some((cs, n)),
_ => {}
}
run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height });
j = next;
}
// Not one picture as a whole: the rows drawn one under the other from the start may be.
if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) {
run = drawn_in_sequence(&ops, i, &xobjects, doc);
j = run_end(&ops, i, run.len());
}
let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else {
// Not a run: keep this op and look again from the next one.
if let Some(op) = ops.get(i) {
out.push(op.clone());
}
i += 1;
continue;
};
if !progress(Stage::Fragments) {
return Err(OptimizeError::Cancelled);
}
let Some((px, w, h)) = merge(doc, &run, n) else {
out.extend(ops.get(i..j).unwrap_or_default().iter().cloned());
i = j;
continue;
};
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("XObject"));
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(i64::from(w)));
dict.set(b"Height".to_vec(), Object::Int(i64::from(h)));
dict.set(b"ColorSpace".to_vec(), cs);
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let image = doc.add(Object::Stream(Stream::flate(dict, &px)));
let name = fresh_name(&xobjects, &mut k);
xobjects.set(name.clone(), Object::Ref(image));
add_xobject(doc, page, &name, image)?;
let (Some(first), Some(last)) = (run.first(), run.last()) else { break };
let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]];
let num = Object::Real;
out.push(Op::new("q", Vec::new()));
out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)]));
out.push(Op::new("Do", vec![Object::Name(name)]));
out.push(Op::new("Q", Vec::new()));
merged += run.len();
report.merged_images += 1;
i = j;
}
if merged > 0 {
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &serialize_ops(&out))));
doc.update_dict(page, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?;
}
Ok(merged)
}
pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<(), OptimizeError> {
for p in pages {
report.fragments_merged += merge_page(doc, *p, report, progress)?;
}
Ok(())
}
+12 -5
View File
@@ -11,7 +11,7 @@ use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage};
const MAX_PIXELS: u64 = 80_000_000;
/// The resources of a page, inherited from the page tree if the page has none.
fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
let mut node = Some(page);
for _ in 0..64 {
let d = doc.get(node?).as_dict()?.clone();
@@ -23,7 +23,7 @@ fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
None
}
fn content_bytes(doc: &Document, contents: &Object) -> Vec<u8> {
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec<u8> {
match &*doc.resolve(contents) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
Object::Array(a) => a
@@ -111,7 +111,7 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef
}
/// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3).
fn components(doc: &Document, cs: &Object) -> Option<usize> {
pub(crate) fn components(doc: &Document, cs: &Object) -> Option<usize> {
let cs = doc.resolve(cs);
match &*cs {
Object::Name(n) => match n.as_slice() {
@@ -179,7 +179,7 @@ fn is_dct(s: &Stream) -> Option<bool> {
}
/// 8-bit samples of an image (`n` components), decoded.
fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
pub(crate) fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize);
if is_dct(s)? {
let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?;
@@ -192,7 +192,7 @@ fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
}
}
fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option<Vec<u8>> {
pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option<Vec<u8>> {
use image::imageops::{FilterType, resize};
Some(match n {
1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
@@ -208,6 +208,13 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
Some(out)
}
/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes and no decode array that
/// changes anything. `None` when the colour space isn't grey or RGB.
pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option<bool> {
let n = components(doc, s.dict.get(b"ColorSpace")?)?;
Some(is_dct(s).is_some() && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n)))
}
/// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes
/// nothing. Scanners write it on page images (#490).
fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool {
+57 -1
View File
@@ -1,6 +1,9 @@
//! The PDF Optimizer (execution plan M11.1; Acrobat: Reduce File Size and Optimize PDF ▸
//! Advanced optimization).
//!
//! - **Merge image fragments:** a page picture some PDF printers draw as thousands of images one
//! pixel row high is put back together into one image first (see [`fragments`]), so the image
//! settings can work on it.
//! - **Images:** each image's effective resolution is measured where pages draw it (the
//! smallest over its uses, through form XObjects); colour and grayscale images above a
//! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate.
@@ -20,6 +23,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod audit;
pub mod fragments;
mod images;
mod links;
mod unused;
@@ -45,6 +49,8 @@ pub enum OptimizeError {
pub enum Stage {
/// About to process image `done` (0-based) of `total`; `done == total` once all are done.
Images { done: usize, total: usize },
/// Merging the fragments of a page picture into one image (only when a page has some).
Fragments,
/// Discarding objects, cleaning up links and compressing unencoded streams.
CleanUp,
}
@@ -73,12 +79,18 @@ pub struct ImageSettings {
}
impl ImageSettings {
/// Reduce File Size: bicubic to 150 ppi above 225 ppi, JPEG medium quality.
/// Reduce File Size and the Standard preset: bicubic to 150 ppi above 225 ppi, JPEG medium
/// quality.
pub const REDUCE: ImageSettings = ImageSettings { downsample: true, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Jpeg(60) };
/// The Mobile preset: bicubic to 96 ppi above 144 ppi, JPEG high quality. Pages read on a
/// screen; text in scans stays legible.
pub const MOBILE: ImageSettings = ImageSettings { downsample: true, target_ppi: 96.0, above_ppi: 144.0, compression: Compression::Jpeg(80) };
}
#[derive(Clone, Debug, PartialEq)]
pub struct Settings {
/// Put page pictures drawn as many stacked fragments back together first ([`fragments`]).
pub merge_fragments: bool,
pub color: ImageSettings,
pub gray: ImageSettings,
pub discard_thumbnails: bool,
@@ -98,6 +110,7 @@ impl Default for Settings {
/// Acrobat's Reduce File Size choices.
fn default() -> Self {
Self {
merge_fragments: true,
color: ImageSettings::REDUCE,
gray: ImageSettings::REDUCE,
discard_thumbnails: true,
@@ -111,9 +124,49 @@ impl Default for Settings {
}
}
/// The optimizer's ready-made settings, as in Acrobat's Settings menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Preset {
/// Reduce File Size's choices ([`Settings::default`]).
Standard,
/// Smaller files for reading on a phone or screen: images to 96 ppi above 144, JPEG high.
Mobile,
}
impl Preset {
pub const ALL: [Preset; 2] = [Preset::Standard, Preset::Mobile];
/// The name tools use.
pub fn name(self) -> &'static str {
match self {
Preset::Standard => "standard",
Preset::Mobile => "mobile",
}
}
pub fn from_name(name: &str) -> Option<Preset> {
Preset::ALL.into_iter().find(|p| p.name() == name)
}
pub fn settings(self) -> Settings {
match self {
Preset::Standard => Settings::default(),
Preset::Mobile => Settings { color: ImageSettings::MOBILE, gray: ImageSettings::MOBILE, ..Settings::default() },
}
}
/// The preset `settings` are, if they are one.
pub fn of(settings: &Settings) -> Option<Preset> {
Preset::ALL.into_iter().find(|p| p.settings() == *settings)
}
}
/// What an optimization did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Report {
/// Images merged from fragments, and how many fragments went into them.
pub merged_images: usize,
pub fragments_merged: usize,
pub images: usize,
pub images_resampled: usize,
pub images_recompressed: usize,
@@ -144,6 +197,9 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, Optim
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)?;
if settings.merge_fragments {
fragments::run(doc, &pages, &mut report, progress)?;
}
images::run(doc, &pages, settings, &mut report, progress)?;
if !progress(Stage::CleanUp) {
return Err(OptimizeError::Cancelled);