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
+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