M11: Optimize keeps a JPEG's colour transform (#1201)

Optimize decoded DCT images with the generic JPEG decoder, which converts
three components from YCbCr to RGB whenever the JPEG itself doesn't say
otherwise, and then dropped /DecodeParms. An RGB JPEG stored untransformed
(/ColorTransform 0 and no Adobe marker) therefore came out in other colours
when recompressed, resampled or merged from strips.

The samples are now read with the colour transform ISO 32000-2 §7.4.8
gives them: an Adobe APP14 marker's flag, else /ColorTransform (default 1).
Untransformed samples are taken as stored, and the result is written as a
plain YCbCr JPEG (or Flate), which needs no /DecodeParms. When the marker
and /ColorTransform disagree, a value is out of range, the parameters are
not paired with the filter as §7.4 pairs them, or the decoder reads the
components otherwise (named R, G and B), the image is left as it is.

The tests draw each image with PdfCraft's renderer before and after
Optimize: RGB JPEGs with and without an Adobe marker under each
/ColorTransform, CMYK JPEGs (still left alone) and JPEG strips merged into
one picture.

Part of #1201.
This commit is contained in:
Guillem Hermida
2026-10-16 17:28:19 +02:00
parent f0c9901c39
commit 359a3f2f09
9 changed files with 512 additions and 19 deletions
Generated
+1
View File
@@ -4224,6 +4224,7 @@ dependencies = [
"pdfcraft-model",
"pdfcraft-render",
"thiserror 2.0.21",
"zune-jpeg",
]
[[package]]
+1
View File
@@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
+2
View File
@@ -18,6 +18,8 @@ pdfcraft-annot = { workspace = true }
thiserror.workspace = true
# JPEG decoding (zune-jpeg) and encoding, and bicubic resampling; already in the tree.
image = { version = "0.25", default-features = false, features = ["jpeg"] }
# The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0).
zune-jpeg = "0.5"
[dev-dependencies]
pdfcraft-render = { workspace = true }
+6 -3
View File
@@ -25,7 +25,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
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,
image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT.
image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. A JPEG is read with
its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one
stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left
alone, since readers differ on which wins.
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
@@ -34,5 +37,5 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories,
then this, then merges identical objects and writes a full, compressed save
(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a
progress bar). JPEG decoding and
encoding and resampling come from the `image` crate.
progress bar). JPEG decoding comes from `zune-jpeg` (the decoder behind `image` and the
renderer), JPEG encoding and resampling from the `image` crate.
+1 -1
View File
@@ -157,7 +157,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec<u8>, u32, u3
}
let obj = doc.get(f.image);
let Object::Stream(s) = &*obj else { return None };
let px = pixels(s, n)?;
let px = pixels(doc, 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)?);
}
+134 -12
View File
@@ -228,20 +228,134 @@ fn is_dct(s: &Stream) -> Option<bool> {
}
}
/// 8-bit samples of an image (`n` components), decoded.
pub(crate) fn pixels(s: &Stream, n: usize) -> Option<Vec<u8>> {
/// 8-bit samples of an image (`n` components), decoded. `None` for a JPEG whose colour transform
/// readers may disagree about ([`ycc_encoded`]).
pub(crate) fn pixels(doc: &Document, 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()?;
// Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them).
let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() };
(v.len() == w * h * n).then_some(v)
// The colour transform matters to three components only (§7.4.8). CMYK and YCCK JPEGs
// never get here: the colour space check excludes them.
let ycc = n == 3 && ycc_encoded(doc, s)?;
jpeg_samples(&s.raw, w, h, n, ycc)
} else {
let v = s.decoded().ok()?;
(v.len() >= w * h * n).then(|| v[..w * h * n].to_vec())
}
}
/// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2
/// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's
/// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default.
///
/// `None`, so the image is left as it is, when readers may disagree: the marker and
/// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where
/// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters
/// or the JPEG's segments can't be read.
fn ycc_encoded(doc: &Document, s: &Stream) -> Option<bool> {
let entry = color_transform(doc, s)?;
match adobe_transform(&s.raw)? {
Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1),
Some(_) => None,
None => match entry {
None | Some(1) => Some(true),
Some(0) => Some(false),
Some(_) => None,
},
}
}
/// The /ColorTransform of a JPEG stream's filter parameters: `Some(None)` without one. `None`
/// if it isn't an integer, or the parameters aren't a dictionary paired with the filter as
/// §7.4 pairs them (a dictionary for a filter name, a one-element array for a one-filter array).
fn color_transform(doc: &Document, s: &Stream) -> Option<Option<i64>> {
let Some(parms) = s.dict.get(b"DecodeParms") else { return Some(None) };
let parms = doc.resolve(parms);
let parms = match (s.dict.get(b"Filter"), &*parms) {
(Some(Object::Array(_)), Object::Array(a)) if a.len() == 1 => doc.resolve(a.first()?),
(Some(Object::Name(_)), _) | (_, Object::Null) => parms.clone(),
_ => return None,
};
match &*parms {
Object::Null => Some(None),
Object::Dict(d) => match d.get(b"ColorTransform").map(|c| doc.resolve(c)).as_deref() {
None => Some(None),
Some(Object::Int(c)) => Some(Some(*c)),
Some(_) => None,
},
_ => None,
}
}
/// The transform flag of a JPEG's Adobe APP14 segment (0: samples stored as they are,
/// 1: YCbCr, 2: YCCK), the last one if there are several, as decoders read them; `Some(None)`
/// without one. `None` if the segments before the first scan can't be read.
fn adobe_transform(data: &[u8]) -> Option<Option<u8>> {
if data.get(..2)? != [0xFF, 0xD8] {
return None;
}
let mut flag = None;
let mut i = 2usize;
// Every step moves on by at least one byte, and reading past the end returns.
loop {
if *data.get(i)? != 0xFF {
// Stray bytes between segments, which decoders skip.
i = i.checked_add(1)?;
continue;
}
let marker = *data.get(i.checked_add(1)?)?;
match marker {
// A fill byte before the marker.
0xFF => i = i.checked_add(1)?,
// The first scan (or the end): no header segments after it.
0xDA | 0xD9 => return Some(flag),
// Markers without a segment: TEM, RSTn, SOI.
0x01 | 0xD0..=0xD8 => i = i.checked_add(2)?,
_ => {
let len = usize::from(u16::from_be_bytes([*data.get(i.checked_add(2)?)?, *data.get(i.checked_add(3)?)?]));
// The length counts its own two bytes: anything shorter is malformed.
let end = i.checked_add(2)?.checked_add(len)?;
let segment = data.get(i.checked_add(4)?..end)?;
if marker == 0xEE
&& let Some(rest) = segment.strip_prefix(b"Adobe")
{
// Version, flags0 and flags1 (two bytes each), then the transform flag.
flag = Some(*rest.get(6)?);
}
i = end;
}
}
}
}
/// 8-bit samples of a JPEG `w` × `h` with `n` components (grey or RGB): YCbCr converted to RGB
/// if `ycc`, otherwise the samples as stored. `None` if the JPEG doesn't match (size or number
/// of components), is too large, or its decoder reads the samples otherwise (for example
/// components named R, G and B, which decoders take as stored, while `ycc` says YCbCr).
fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option<Vec<u8>> {
use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};
if (w.checked_mul(h)? as u64) > MAX_PIXELS {
return None;
}
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
dec.decode_headers().ok()?;
if dec.dimensions()? != (w, h) || usize::from(dec.info()?.components) != n {
return None;
}
let out = match (n, ycc, dec.input_colorspace()?) {
(1, _, ColorSpace::Luma) => ColorSpace::Luma,
(3, true, ColorSpace::YCbCr) => ColorSpace::RGB,
// As stored: no conversion either way. The decoder reports an Adobe marker's 0 as CMYK
// until it decodes, and then reads three such components as RGB.
(3, false, ColorSpace::YCbCr) => ColorSpace::YCbCr,
(3, false, ColorSpace::RGB | ColorSpace::CMYK) => ColorSpace::RGB,
_ => return None,
};
dec.set_options(options.jpeg_set_out_colorspace(out));
let px = dec.decode().ok()?;
(px.len() == w.checked_mul(h)?.checked_mul(n)?).then_some(px)
}
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 {
@@ -258,11 +372,17 @@ 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.
/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes, no decode array that
/// changes anything, and for an RGB JPEG a colour transform readers agree on. `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)))
let dct = is_dct(s);
Some(
dct.is_some()
&& s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n))
&& (n != 3 || dct == Some(false) || ycc_encoded(doc, s).is_some()),
)
}
/// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes
@@ -274,7 +394,9 @@ fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool {
}
/// A new image stream from 8-bit samples: the original dictionary with the new size and filter.
/// A decode array is dropped: only images without one, or with the default one, get here.
/// A decode array is dropped: only images without one, or with the default one, get here. So are
/// the filter parameters: the samples are decoded, and a JPEG written here is plain YCbCr with no
/// Adobe marker, which is what a missing /ColorTransform means.
fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option<Stream> {
let mut d = old.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] {
@@ -335,7 +457,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us
} else {
(w, h)
};
let px = pixels(s, n)?;
let px = pixels(doc, s, n)?;
let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px };
let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?;
// A soft mask must keep the image's dimensions: resample it with the image (lossless).
@@ -348,7 +470,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us
return None;
}
let (mw, mh) = (m.dict.int(b"Width")? as u32, m.dict.int(b"Height")? as u32);
let mpx = pixels(m, 1)?;
let mpx = pixels(doc, m, 1)?;
let mpx = resample(&mpx, 1, mw, mh, nw, nh)?;
smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?));
}
+2 -1
View File
@@ -9,7 +9,8 @@
//! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate.
//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode
//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default,
//! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone.
//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform`
//! disagree) are left alone.
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
//! print settings.
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
@@ -0,0 +1,363 @@
//! Optimize keeps a JPEG's colours whatever its colour transform says (#1201).
//!
//! A JPEG with three or four components can store its samples as they are or converted to YCbCr
//! (YCCK). An Adobe APP14 marker in the JPEG says which; without one, the filter's
//! `/ColorTransform` does (ISO 32000-2 §7.4.8, default 1 for three components). Each image here
//! is drawn by PdfCraft's own renderer before and after Optimize, and the two must show the same
//! colours, up to what a JPEG re-encode loses.
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
use pdfcraft_optimize::{Settings, optimize};
/// A photo-like RGB picture: colourful gradients with a little texture, so the colour transform
/// matters everywhere and a medium-quality JPEG is smaller than a maximum-quality one.
fn photo(w: u32, h: u32) -> Vec<u8> {
let mut px = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
let t = ((x ^ y) & 7) as u8;
px.push((x * 255 / w) as u8 ^ t);
px.push((y * 255 / h) as u8);
px.push((255 - (x + y) * 127 / (w + h)) as u8 ^ (t << 2));
}
}
px
}
fn rgb_jpeg(px: &[u8], w: u32, h: u32) -> Vec<u8> {
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 100).encode(px, w, h, image::ExtendedColorType::Rgb8).unwrap();
out
}
/// The JPEG's segments before its first scan: (marker, start of the marker, end of the segment).
fn segments(jpeg: &[u8]) -> Vec<(u8, usize, usize)> {
assert_eq!(&jpeg[..2], &[0xFF, 0xD8]);
let (mut i, mut out) = (2, Vec::new());
loop {
assert_eq!(jpeg[i], 0xFF, "a marker at {i}");
let marker = jpeg[i + 1];
let end = i + 2 + u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize;
out.push((marker, i, end));
if marker == 0xDA {
return out;
}
i = end;
}
}
/// `jpeg` with an Adobe APP14 segment whose transform flag is `transform` (0: samples stored as
/// they are, 1: YCbCr, 2: YCCK).
fn with_adobe(jpeg: &[u8], transform: u8) -> Vec<u8> {
assert!(segments(jpeg).iter().all(|s| s.0 != 0xEE), "no Adobe segment yet");
let mut v = jpeg[..2].to_vec();
v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]);
v.extend_from_slice(&jpeg[2..]);
v
}
/// `jpeg` with its three components named R, G and B (in the frame and scan headers), which
/// JPEG decoders read as samples stored untransformed.
fn with_rgb_ids(jpeg: &[u8]) -> Vec<u8> {
let mut v = jpeg.to_vec();
let mut ids = Vec::new();
for (marker, i, _) in segments(jpeg) {
match marker {
0xC0 => {
assert_eq!(v[i + 9], 3);
for k in 0..3 {
ids.push(v[i + 10 + 3 * k]);
v[i + 10 + 3 * k] = b"RGB"[k];
}
}
0xDA => {
for k in 0..v[i + 4] as usize {
let id = v[i + 5 + 2 * k];
v[i + 5 + 2 * k] = b"RGB"[ids.iter().position(|&x| x == id).unwrap()];
}
}
_ => {}
}
}
v
}
/// A baseline JPEG `w` × `h` (multiples of 8) with four components, every 8 × 8 block one flat
/// colour `block(bx, by)` as stored, and an Adobe APP14 segment if `adobe` is given.
fn four_component_jpeg(w: u16, h: u16, adobe: Option<u8>, block: impl Fn(u16, u16) -> [u8; 4]) -> Vec<u8> {
fn put(bits: &mut Vec<bool>, value: u32, n: u32) {
bits.extend((0..n).rev().map(|i| value >> i & 1 == 1));
}
let mut v = vec![0xFF, 0xD8];
if let Some(t) = adobe {
v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, t]);
}
// Every quantiser 1: a flat block's DC coefficient is 8 × (sample − 128), exactly.
v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]);
v.extend_from_slice(&[1; 64]);
v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8]);
v.extend_from_slice(&h.to_be_bytes());
v.extend_from_slice(&w.to_be_bytes());
v.push(4);
for id in 1..=4 {
v.extend_from_slice(&[id, 0x11, 0]);
}
// DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0".
v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
v.extend(0..12u8);
v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]);
let mut bits = Vec::new();
let mut pred = [0i32; 4];
for by in 0..h / 8 {
for bx in 0..w / 8 {
for (c, s) in block(bx, by).into_iter().enumerate() {
let dc = (i32::from(s) - 128) * 8;
let diff = dc - pred[c];
pred[c] = dc;
let category = 32 - diff.unsigned_abs().leading_zeros();
put(&mut bits, category, 4);
put(&mut bits, if diff < 0 { (diff + (1 << category) - 1) as u32 } else { diff as u32 }, category);
put(&mut bits, 0, 1);
}
}
}
bits.resize(bits.len().div_ceil(8) * 8, true);
for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) {
v.push(byte);
if byte == 0xFF {
v.push(0);
}
}
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
/// An image XObject holding `jpeg`.
fn jpeg_image(doc: &mut Document, w: u32, h: u32, colour_space: &str, jpeg: Vec<u8>, parms: Parms) -> ObjRef {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w as i64));
d.set(b"Height".to_vec(), Object::Int(h as i64));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name(colour_space));
let ct = |c: i64| {
let mut p = Dict::new();
p.set(b"ColorTransform".to_vec(), Object::Int(c));
Object::Dict(p)
};
match parms {
Parms::None => d.set(b"Filter".to_vec(), Object::name("DCTDecode")),
Parms::Dict(c) => {
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
d.set(b"DecodeParms".to_vec(), ct(c));
}
Parms::Array(c) => {
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("DCTDecode")]));
d.set(b"DecodeParms".to_vec(), Object::Array(vec![ct(c)]));
}
}
doc.add(Object::Stream(Stream::from_raw(d, jpeg)))
}
/// How an image's `/DecodeParms` gives its `/ColorTransform`.
#[derive(Clone, Copy, Debug)]
enum Parms {
None,
/// `/Filter /DCTDecode /DecodeParms << /ColorTransform c >>`.
Dict(i64),
/// `/Filter [/DCTDecode] /DecodeParms [<< /ColorTransform c >>]`.
Array(i64),
}
/// A document with one page, `w` × `h` pt, whose content is `content` drawing `images`.
fn one_page(doc: &mut Document, w: u32, h: u32, images: &[(String, ObjRef)], content: &str) {
let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
let mut x = Dict::new();
for (name, r) in images {
x.set(name.as_bytes().to_vec(), Object::Ref(*r));
}
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(x));
let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec())));
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(), (w as i64).into(), (h as i64).into()]));
p.set(b"Resources".to_vec(), Object::Dict(res));
p.set(b"Contents".to_vec(), Object::Ref(c));
let r = doc.add(Object::Dict(p));
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
}
/// A document whose one page shows `jpeg` (`w` × `h` px) at `ppi`.
fn showing(w: u32, h: u32, ppi: u32, colour_space: &str, jpeg: Vec<u8>, parms: Parms) -> (Document, ObjRef) {
let mut doc = Document::new_empty();
let img = jpeg_image(&mut doc, w, h, colour_space, jpeg, parms);
let (pw, ph) = (w * 72 / ppi, h * 72 / ppi);
one_page(&mut doc, pw, ph, &[("Im".to_string(), img)], &format!("q {pw} 0 0 {ph} 0 0 cm /Im Do Q"));
(doc, img)
}
fn stream(doc: &Document, r: ObjRef) -> Stream {
match &*doc.get(r) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
}
}
/// The page as PdfCraft's renderer draws it, `scale` device pixels per point: RGB rows.
fn render(doc: &Document, scale: f32) -> (u32, u32, Vec<u8>) {
let bytes = write_full(doc, &SaveOptions::default()).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
(out.width, out.height, out.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect())
}
/// The mean difference per colour component between two renders of one page, leaving out a
/// two-pixel border (where the picture's edge is smoothed).
fn mean_difference(a: &(u32, u32, Vec<u8>), b: &(u32, u32, Vec<u8>)) -> f64 {
assert_eq!((a.0, a.1), (b.0, b.1), "renders of one page size");
let (w, h) = (a.0 as usize, a.1 as usize);
let (mut sum, mut count) = (0u64, 0u64);
for y in 2..h - 2 {
for x in 2..w - 2 {
for c in 0..3 {
let i = (y * w + x) * 3 + c;
sum += u64::from(a.2[i].abs_diff(b.2[i]));
count += 1;
}
}
}
sum as f64 / count as f64
}
/// Re-encoding a JPEG at medium quality moves a component by about this much on average; a
/// colour transform applied or dropped wrongly moves it by tens.
const TOLERANCE: f64 = 4.0;
#[derive(Clone, Copy, Debug, PartialEq)]
enum Outcome {
/// Re-encoded (smaller), as a plain YCbCr JPEG with no `/DecodeParms`.
Reencoded,
/// Left exactly as it was.
Untouched,
}
#[test]
fn optimize_keeps_the_colours_of_an_rgb_jpeg_whatever_its_colour_transform() {
use Outcome::*;
let (w, h) = (128, 128);
let plain = rgb_jpeg(&photo(w, h), w, h);
// What the cases rely on: one JPEG read with and without its colour transform shows very
// different colours, so a transform lost or added by Optimize can't hide in the tolerance.
let transformed = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::None).0, 1.0);
let as_stored = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::Dict(0)).0, 1.0);
let apart = mean_difference(&transformed, &as_stored);
assert!(apart > 10.0 * TOLERANCE, "the fixture tells the two readings apart: {apart:.1}");
let mut failures = Vec::new();
for (what, adobe, rgb_ids, parms, expect) in [
("no Adobe marker, no /ColorTransform", None, false, Parms::None, Reencoded),
("no Adobe marker, /ColorTransform 1", None, false, Parms::Dict(1), Reencoded),
("no Adobe marker, /ColorTransform 0", None, false, Parms::Dict(0), Reencoded),
("no Adobe marker, /ColorTransform 0 in a filter array", None, false, Parms::Array(0), Reencoded),
("Adobe YCbCr marker", Some(1), false, Parms::None, Reencoded),
("Adobe YCbCr marker, /ColorTransform 1", Some(1), false, Parms::Dict(1), Reencoded),
("Adobe untransformed marker", Some(0), false, Parms::None, Reencoded),
("Adobe untransformed marker, /ColorTransform 0", Some(0), false, Parms::Dict(0), Reencoded),
// The marker overrides /ColorTransform in the standard, but readers differ (PdfCraft's
// renderer lets /ColorTransform 0 win): the image is left as it is.
("Adobe YCbCr marker, /ColorTransform 0", Some(1), false, Parms::Dict(0), Untouched),
("Adobe untransformed marker, /ColorTransform 1", Some(0), false, Parms::Dict(1), Untouched),
// Components named R, G and B: JPEG decoders read them untransformed, the standard's
// default transforms them. Only an explicit /ColorTransform 0 settles it.
("components R, G, B, no /ColorTransform", None, true, Parms::None, Untouched),
("components R, G, B, /ColorTransform 0", None, true, Parms::Dict(0), Reencoded),
] {
let mut jpeg = plain.clone();
if rgb_ids {
jpeg = with_rgb_ids(&jpeg);
}
if let Some(t) = adobe {
jpeg = with_adobe(&jpeg, t);
}
let (mut doc, img) = showing(w, h, 72, "DeviceRGB", jpeg, parms);
let original = stream(&doc, img);
let before = render(&doc, 1.0);
// Reduce File Size: an image at 72 ppi isn't resampled, only recompressed as JPEG 60.
let report = optimize(&mut doc, &Settings::default()).unwrap();
let after = render(&doc, 1.0);
let new = stream(&doc, img);
let outcome = if new == original {
Untouched
} else {
assert_eq!(new.dict.name(b"Filter"), Some(&b"DCTDecode"[..]), "{what}");
assert!(!new.dict.contains(b"DecodeParms"), "{what}: a plain JPEG has no colour transform to state");
assert_eq!(report.images_recompressed, 1, "{what}");
Reencoded
};
let difference = mean_difference(&before, &after);
if outcome != expect || difference > TOLERANCE {
failures.push(format!("{what}: {outcome:?} (expected {expect:?}), colours moved by {difference:.1}"));
}
}
assert!(failures.is_empty(), "\n{}", failures.join("\n"));
}
#[test]
fn optimize_leaves_cmyk_jpegs_as_they_are_with_or_without_an_adobe_marker() {
// 256 px drawn at 288 ppi: Reduce File Size would resample a colour space it handles.
let (w, h) = (256u16, 256u16);
let block = |bx: u16, by: u16| [(bx * 8) as u8, (by * 8) as u8, (255 - (bx + by) * 4) as u8, ((bx ^ by) * 8) as u8];
for (adobe, parms) in [
(None, Parms::None),
(None, Parms::Dict(0)),
(None, Parms::Dict(1)),
(Some(0), Parms::None),
(Some(0), Parms::Dict(1)),
(Some(2), Parms::None),
(Some(2), Parms::Dict(0)),
] {
let what = format!("Adobe marker {adobe:?}, {parms:?}");
let jpeg = four_component_jpeg(w, h, adobe, block);
let (mut doc, img) = showing(u32::from(w), u32::from(h), 288, "DeviceCMYK", jpeg, parms);
let original = doc.get(img);
let before = render(&doc, 4.0);
optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(doc.get(img), original, "{what}: left as it is");
assert!(before == render(&doc, 4.0), "{what}: the page looks the same");
}
}
#[test]
fn merging_jpeg_strips_keeps_their_colour_transform() {
// A page picture printed as 16 JPEG strips stored untransformed (/ColorTransform 0, no
// Adobe marker): the merged image must show the strips' colours, not their samples converted
// from YCbCr.
let (w, h, rows) = (128u32, 128u32, 8u32);
let px = photo(w, h);
let mut doc = Document::new_empty();
let mut images = Vec::new();
let mut content = String::new();
for k in 0..h / rows {
let band = &px[(k * rows * w * 3) as usize..((k + 1) * rows * w * 3) as usize];
let img = jpeg_image(&mut doc, w, rows, "DeviceRGB", rgb_jpeg(band, w, rows), Parms::Dict(0));
images.push((format!("S{k}"), img));
content += &format!("q {w} 0 0 {rows} 0 {} cm /S{k} Do Q\n", h - (k + 1) * rows);
}
one_page(&mut doc, w, h, &images, &content);
let before = render(&doc, 1.0);
let report = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(report.merged_images, 1, "{report:?}");
let difference = mean_difference(&before, &render(&doc, 1.0));
assert!(difference <= TOLERANCE, "colours moved by {difference:.1}");
}
+2 -2
View File
@@ -7284,8 +7284,8 @@ milestone = "M11"
status = "partial"
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"]
notes = "JPEG (quality steps) and ZIP; no JPEG 2000"
evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/optimize/tests/jpeg_color_transform.rs::optimize_keeps_the_colours_of_an_rgb_jpeg_whatever_its_colour_transform"]
notes = "JPEG (quality steps) and ZIP; a JPEG is decoded with its colour transform (Adobe marker, else /ColorTransform), and one whose marker and /ColorTransform disagree is left as it is; no JPEG 2000"
[[feature]]
id = "optimize.mono-compression"