diff --git a/Cargo.lock b/Cargo.lock index 91ed3c490..0d894e46a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4224,6 +4224,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-render", "thiserror 2.0.21", + "zune-jpeg", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..f2cfb655a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 184ee9697..c00bfba75 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -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 } diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 32915af6b..10a5f4d7e 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -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. diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee0..302c4e532 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -157,7 +157,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, 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)?); } diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674..1a9a918ac 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -228,20 +228,134 @@ fn is_dct(s: &Stream) -> Option { } } -/// 8-bit samples of an image (`n` components), decoded. -pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { +/// 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> { 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 { + 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> { + 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> { + 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> { + 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> { 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> { 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 { 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 { 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)?)); } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b6769..0d9737d68 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -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 diff --git a/crates/optimize/tests/jpeg_color_transform.rs b/crates/optimize/tests/jpeg_color_transform.rs new file mode 100644 index 000000000..77a329352 --- /dev/null +++ b/crates/optimize/tests/jpeg_color_transform.rs @@ -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 { + 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 { + 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 { + 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 { + 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, block: impl Fn(u16, u16) -> [u8; 4]) -> Vec { + fn put(bits: &mut Vec, 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, 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, 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) { + 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), b: &(u32, u32, Vec)) -> 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}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94..29dd75701 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"