mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
Generated
+1
@@ -4224,6 +4224,7 @@ dependencies = [
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)?));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user