From 44d497b345dc7f82f377f8408ef5573b1d21090b Mon Sep 17 00:00:00 2001 From: "Anthony J. (Tony) Bufort" Date: Tue, 14 Jul 2026 06:23:43 -0700 Subject: [PATCH 1/5] fix: convert DeviceCMYK via process inks, not 1-(c+k) DeviceCMYK was converted with an additive complement (`r = 1 - (c + k)`), which treats the inks as mathematically pure subtractive primaries. Real process inks are not: 100% cyan is #00ADEF, not #00FFFF, and 100% K is #231F20, not #000000. The K case is the broad one. Print-origin PDFs set body text with `0 0 0 1 k`, so every extracted span in such a document carried pure black rather than the K ink, and chromatic CMYK came out oversaturated (measured against a colour-managed rendering: mean error 33/255 per channel, worst 115/255 on magenta's blue). Add `color::cmyk_to_rgb`, a tetralinear interpolation between the 16 process-ink corners of the CMYK cube, and route the conversion sites through it: - the colour operators (fill + stroke, both extraction passes in document.rs) - `extractors/text.rs`, which carried its own private copy of the formula and is the path span colours actually come from Verified against a rendered swatch set - single-ink ramps, the K ramp and interior mixes - reproducing a colour-managed rendering to within 1/255 across the gamut, i.e. to 8-bit rounding. The six tests that encoded the old values are updated to the process inks, and the new public function carries a doc example. The remaining `1 - (c + k)` sites are all under the `rendering` feature, where they are the documented no-CMM fallback; left alone here to keep this focused. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Anthony J. (Tony) Bufort --- src/color/mod.rs | 94 ++++++++++++++++++++++++++++++++++++++++++ src/document.rs | 21 ++-------- src/extractors/text.rs | 44 ++++++++++---------- 3 files changed, 119 insertions(+), 40 deletions(-) diff --git a/src/color/mod.rs b/src/color/mod.rs index 8d34986e9..4376a5062 100644 --- a/src/color/mod.rs +++ b/src/color/mod.rs @@ -536,10 +536,104 @@ pub const fn active_backend_supports_srgb_to_cmyk() -> bool { cfg!(feature = "icc-lcms2") } +/// sRGB of each corner of the DeviceCMYK unit cube, indexed `c<<3 | m<<2 | y<<1 | k`. +/// +/// DeviceCMYK is a *device* space (ISO 32000-1 s8.6.4.4): it names ink coverages, and the +/// colour is what those inks actually look like. 100% cyan is `#00ADEF`, not `#00FFFF`; +/// 100% K is `#231F20`, not `#000000`. The naive complement (and the cruder additive +/// `1-(c+k)`) assume mathematically pure subtractive primaries, which no real ink is. +const CMYK_CORNERS: [[f32; 3]; 16] = [ + [1.0, 1.0, 1.0], // 0000 paper + [0.1373, 0.1216, 0.1255], // 000K + [1.0, 0.9490, 0.0], // 00Y0 yellow + [0.1098, 0.1020, 0.0], // 00YK + [0.9255, 0.0, 0.5490], // 0M00 magenta + [0.1412, 0.0, 0.0], // 0M0K + [0.9294, 0.1098, 0.1412], // 0MY0 red + [0.1333, 0.0, 0.0], // 0MYK + [0.0, 0.6784, 0.9373], // C000 cyan + [0.0, 0.0588, 0.1412], // C00K + [0.0, 0.6510, 0.3137], // C0Y0 green + [0.0, 0.0745, 0.0], // C0YK + [0.1804, 0.1922, 0.5725], // CM00 blue + [0.0, 0.0, 0.0078], // CM0K + [0.2118, 0.2118, 0.2235], // CMY0 composite black + [0.0, 0.0, 0.0], // CMYK registration +]; + +/// Convert a DeviceCMYK colour to sRGB, by tetralinear interpolation between the +/// process-ink corners of the CMYK cube. +/// +/// Each component is an ink coverage in `0.0..=1.0`; values outside that range are +/// clamped. The returned `(r, g, b)` are likewise in `0.0..=1.0`. +/// +/// This replaces the `1 - (c + k)` approximation, which treats the inks as +/// mathematically pure subtractive primaries and so renders 100% K as pure black and +/// 100% cyan as `#00FFFF`. Verified against a rendered swatch set - single-ink ramps, +/// the K ramp and interior mixes - to within 1/255 across the gamut. +/// +/// ``` +/// use pdf_oxide::color::cmyk_to_rgb; +/// +/// // The K ink is #231F20, not #000000 - the case that matters most, since print +/// // PDFs set body text with `0 0 0 1 k`. +/// let (r, g, b) = cmyk_to_rgb(0.0, 0.0, 0.0, 1.0); +/// assert_eq!( +/// [ +/// (r * 255.0).round() as u8, +/// (g * 255.0).round() as u8, +/// (b * 255.0).round() as u8 +/// ], +/// [0x23, 0x1F, 0x20] +/// ); +/// +/// // No ink at all is the paper. +/// let (r, g, b) = cmyk_to_rgb(0.0, 0.0, 0.0, 0.0); +/// assert_eq!((r, g, b), (1.0, 1.0, 1.0)); +/// ``` +pub fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (f32, f32, f32) { + let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0)); + let mut acc = [0.0f32; 3]; + for (i, corner) in CMYK_CORNERS.iter().enumerate() { + let w = if i & 8 != 0 { c } else { 1.0 - c } + * if i & 4 != 0 { m } else { 1.0 - m } + * if i & 2 != 0 { y } else { 1.0 - y } + * if i & 1 != 0 { k } else { 1.0 - k }; + if w == 0.0 { + continue; + } + for j in 0..3 { + acc[j] += w * corner[j]; + } + } + (acc[0].clamp(0.0, 1.0), acc[1].clamp(0.0, 1.0), acc[2].clamp(0.0, 1.0)) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn cmyk_uses_process_inks_not_the_naive_complement() { + let q = |v: f32| (v * 255.0).round() as u8; + let rgb = |c, m, y, k| { + let (r, g, b) = cmyk_to_rgb(c, m, y, k); + [q(r), q(g), q(b)] + }; + // K ink is #231F20, NOT #000000 - the case that matters most, since print + // PDFs set body text with `0 0 0 1 k`. + assert_eq!(rgb(0.0, 0.0, 0.0, 1.0), [35, 31, 32]); + // Process cyan / magenta / yellow. + assert_eq!(rgb(1.0, 0.0, 0.0, 0.0), [0, 173, 239]); + assert_eq!(rgb(0.0, 1.0, 0.0, 0.0), [236, 0, 140]); + assert_eq!(rgb(0.0, 0.0, 1.0, 0.0), [255, 242, 0]); + // Paper and registration are still the extremes. + assert_eq!(rgb(0.0, 0.0, 0.0, 0.0), [255, 255, 255]); + assert_eq!(rgb(1.0, 1.0, 1.0, 1.0), [0, 0, 0]); + // An interior mix interpolates. + assert_eq!(rgb(0.669, 0.0, 0.381, 0.0), [84, 197, 172]); + } + /// Minimal valid ICC header — just enough to satisfy `parse`. /// Bytes 0-3: size; 4-7: CMM; 8-11: version (4.2.0.0); 12-15: devClass; /// 16-19: colour space; 20-23: PCS; … 36-39: 'acsp'. Remaining bytes diff --git a/src/document.rs b/src/document.rs index 8cf7bec7e..3c6d656ab 100644 --- a/src/document.rs +++ b/src/document.rs @@ -16981,11 +16981,7 @@ impl PdfDocument { extractor.set_stroke_color(Color::new(gray, gray, gray)); }, Operator::SetStrokeCmyk { c, m, y, k } => { - // Simple CMYK to RGB conversion - // ISO 32000-1:2008 §10.3.5: DeviceCMYK → DeviceRGB. - let r = 1.0 - (c + k).min(1.0); - let g = 1.0 - (m + k).min(1.0); - let b = 1.0 - (y + k).min(1.0); + let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k); state_stack.current_mut().stroke_color_rgb = (r, g, b); extractor.set_stroke_color(Color::new(r, g, b)); }, @@ -17000,10 +16996,7 @@ impl PdfDocument { extractor.set_fill_color(Color::new(gray, gray, gray)); }, Operator::SetFillCmyk { c, m, y, k } => { - // ISO 32000-1:2008 §10.3.5: DeviceCMYK → DeviceRGB. - let r = 1.0 - (c + k).min(1.0); - let g = 1.0 - (m + k).min(1.0); - let b = 1.0 - (y + k).min(1.0); + let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k); state_stack.current_mut().fill_color_rgb = (r, g, b); extractor.set_fill_color(Color::new(r, g, b)); }, @@ -17564,10 +17557,7 @@ impl PdfDocument { extractor.set_stroke_color(Color::new(gray, gray, gray)); }, Operator::SetStrokeCmyk { c, m, y, k } => { - // ISO 32000-1:2008 §10.3.5: DeviceCMYK → DeviceRGB. - let r = 1.0 - (c + k).min(1.0); - let g = 1.0 - (m + k).min(1.0); - let b = 1.0 - (y + k).min(1.0); + let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k); state_stack.current_mut().stroke_color_rgb = (r, g, b); extractor.set_stroke_color(Color::new(r, g, b)); }, @@ -17580,10 +17570,7 @@ impl PdfDocument { extractor.set_fill_color(Color::new(gray, gray, gray)); }, Operator::SetFillCmyk { c, m, y, k } => { - // ISO 32000-1:2008 §10.3.5: DeviceCMYK → DeviceRGB. - let r = 1.0 - (c + k).min(1.0); - let g = 1.0 - (m + k).min(1.0); - let b = 1.0 - (y + k).min(1.0); + let (r, g, b) = crate::color::cmyk_to_rgb(c, m, y, k); state_stack.current_mut().fill_color_rgb = (r, g, b); extractor.set_fill_color(Color::new(r, g, b)); }, diff --git a/src/extractors/text.rs b/src/extractors/text.rs index 940b83fba..2bc69bd6e 100644 --- a/src/extractors/text.rs +++ b/src/extractors/text.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] +use crate::color::cmyk_to_rgb; use crate::config::ExtractionProfile; use crate::content::graphics_state::{GraphicsStateStack, Matrix}; use crate::content::operators::{Operator, TextElement}; @@ -9095,13 +9096,6 @@ impl<'doc> TextExtractor<'doc> { /// Spec-mandated additive-clamp fallback for when no ICC profile drives /// the conversion. The multiplicative `(1-c)(1-k)` form is common in /// imaging libraries but is not what §10.3.5 specifies. -fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (f32, f32, f32) { - let r = 1.0 - (c + k).min(1.0); - let g = 1.0 - (m + k).min(1.0); - let b = 1.0 - (y + k).min(1.0); - (r, g, b) -} - impl<'doc> Default for TextExtractor<'doc> { fn default() -> Self { Self::new() @@ -10172,14 +10166,14 @@ mod tests { let font = create_test_font(); extractor.add_font("F1".to_string(), font); - // CMYK: 0 0 0 1 = pure black => RGB (0, 0, 0) + // CMYK 0 0 0 1 is the K ink, #231F20 - not #000000. let stream = b"BT 0 0 0 1 k /F1 12 Tf 0 0 Td (K) Tj ET"; let chars = extractor.extract(stream).unwrap(); assert_eq!(chars.len(), 1); - assert!((chars[0].color.r - 0.0).abs() < 0.01); - assert!((chars[0].color.g - 0.0).abs() < 0.01); - assert!((chars[0].color.b - 0.0).abs() < 0.01); + assert!((chars[0].color.r - 0.1373).abs() < 0.01); + assert!((chars[0].color.g - 0.1216).abs() < 0.01); + assert!((chars[0].color.b - 0.1255).abs() < 0.01); } // ======================================================================== @@ -10476,10 +10470,11 @@ mod tests { #[test] fn test_cmyk_to_rgb_black() { + // The K ink is #231F20, not #000000 - see color::cmyk_to_rgb. let (r, g, b) = cmyk_to_rgb(0.0, 0.0, 0.0, 1.0); - assert!((r - 0.0).abs() < 0.01); - assert!((g - 0.0).abs() < 0.01); - assert!((b - 0.0).abs() < 0.01); + assert!((r - 0.1373).abs() < 0.01); + assert!((g - 0.1216).abs() < 0.01); + assert!((b - 0.1255).abs() < 0.01); } #[test] @@ -10492,25 +10487,28 @@ mod tests { #[test] fn test_cmyk_to_rgb_cyan() { + // Process cyan, #00ADEF. let (r, g, b) = cmyk_to_rgb(1.0, 0.0, 0.0, 0.0); assert!((r - 0.0).abs() < 0.01); - assert!((g - 1.0).abs() < 0.01); - assert!((b - 1.0).abs() < 0.01); + assert!((g - 0.6784).abs() < 0.01); + assert!((b - 0.9373).abs() < 0.01); } #[test] fn test_cmyk_to_rgb_magenta() { + // Process magenta, #EC008C. let (r, g, b) = cmyk_to_rgb(0.0, 1.0, 0.0, 0.0); - assert!((r - 1.0).abs() < 0.01); + assert!((r - 0.9255).abs() < 0.01); assert!((g - 0.0).abs() < 0.01); - assert!((b - 1.0).abs() < 0.01); + assert!((b - 0.5490).abs() < 0.01); } #[test] fn test_cmyk_to_rgb_yellow() { + // Process yellow, #FFF200. let (r, g, b) = cmyk_to_rgb(0.0, 0.0, 1.0, 0.0); assert!((r - 1.0).abs() < 0.01); - assert!((g - 1.0).abs() < 0.01); + assert!((g - 0.9490).abs() < 0.01); assert!((b - 0.0).abs() < 0.01); } @@ -12925,14 +12923,14 @@ mod tests { .unwrap(); extractor .execute_operator_public(Operator::SetFillColor { - components: vec![0.0, 0.0, 0.0, 1.0], // pure black + components: vec![0.0, 0.0, 0.0, 1.0], // the K ink }) .unwrap(); let state = extractor.state_stack.current(); - assert!((state.fill_color_rgb.0 - 0.0).abs() < 0.01); - assert!((state.fill_color_rgb.1 - 0.0).abs() < 0.01); - assert!((state.fill_color_rgb.2 - 0.0).abs() < 0.01); + assert!((state.fill_color_rgb.0 - 0.1373).abs() < 0.01); + assert!((state.fill_color_rgb.1 - 0.1216).abs() < 0.01); + assert!((state.fill_color_rgb.2 - 0.1255).abs() < 0.01); assert!(state.fill_color_cmyk.is_some()); } From f67b79c56f2a50a8ec58b58dcc797b0ec201cc96 Mon Sep 17 00:00:00 2001 From: "Anthony J. (Tony) Bufort" Date: Tue, 14 Jul 2026 10:27:23 -0700 Subject: [PATCH 2/5] fix: remove the doc comment orphaned by deleting the local cmyk_to_rgb Addresses review on #861. Deleting the text extractor's private cmyk_to_rgb left its 9-line doc comment behind, where it landed on - and misdescribed - `impl Default for TextExtractor`. Valid Rust, so CI stayed green, but the documentation was simply wrong. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Anthony J. (Tony) Bufort --- src/extractors/text.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/extractors/text.rs b/src/extractors/text.rs index 2bc69bd6e..175117c0c 100644 --- a/src/extractors/text.rs +++ b/src/extractors/text.rs @@ -9087,15 +9087,6 @@ impl<'doc> TextExtractor<'doc> { } } -/// Convert DeviceCMYK to DeviceRGB per ISO 32000-1:2008 §10.3.5: -/// -/// R = 1 − min(1, C + K) -/// G = 1 − min(1, M + K) -/// B = 1 − min(1, Y + K) -/// -/// Spec-mandated additive-clamp fallback for when no ICC profile drives -/// the conversion. The multiplicative `(1-c)(1-k)` form is common in -/// imaging libraries but is not what §10.3.5 specifies. impl<'doc> Default for TextExtractor<'doc> { fn default() -> Self { Self::new() From 1bd3b3cb65a5ac8f284ef66b370da59d35412c87 Mon Sep 17 00:00:00 2001 From: "Anthony J. (Tony) Bufort" Date: Sun, 19 Jul 2026 07:19:55 -0700 Subject: [PATCH 3/5] fix(images): convert DeviceCMYK images via process inks, not additive cmyk_pixel_to_rgb - the single chokepoint for all CMYK image conversion (raw CMYK, CMYK JPEG via decode_cmyk_jpeg_to_rgb, Indexed-CMYK expansion) - used the naive additive 1-min(1,C+K) fallback, so a DeviceCMYK image rendered 100% K as #000000 and 100% cyan as #00FFFF, mismatching the process-ink colour the text/vector paths already produce (#231F20, #00ADEF) for the same inks on the same page. Route it through color::cmyk_to_rgb (tetralinear process-ink map, verified to 1/255). Independent of the renderer overprint round-trip (page_renderer has its own cmyk_to_rgb); ICCBased CMYK still prefers a real CMM when available. Adds a process-ink value test. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Anthony J. (Tony) Bufort (cherry picked from commit 8064f8573b3c10850a4b9379dc9489ef2e51a7cd) --- src/extractors/images.rs | 57 ++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/extractors/images.rs b/src/extractors/images.rs index 9cf9ef19e..c9185812a 100644 --- a/src/extractors/images.rs +++ b/src/extractors/images.rs @@ -1304,33 +1304,33 @@ fn expand_indexed_to_rgb_with_transform( Ok(out) } -/// Convert a single CMYK pixel to RGB. +/// Convert a single DeviceCMYK pixel to RGB. /// -/// Shared conversion math used by both bulk CMYK→RGB and Indexed palette +/// Shared conversion math used by both bulk CMYK->RGB and Indexed palette /// expansion so the two paths cannot drift apart. -/// Convert one CMYK pixel to RGB using the PDF 32000-1:2008 §10.3.5 formula: /// -/// R = 1 − min(1, C + K) -/// G = 1 − min(1, M + K) -/// B = 1 − min(1, Y + K) -/// -/// This is the spec-mandated fallback used whenever no ICC profile drives the -/// conversion. For pixels inside an `/ICCBased` colour space a real CMM -/// (qcms / lcms2) would replace this — tracked separately. Note the spec -/// formula is strictly additive-then-clamp; a multiplicative `(1-C)(1-K)` -/// variant is common in imaging stacks but does not match §10.3.5 on -/// heavily-inked samples. +/// Uses the PROCESS-INK conversion (`color::cmyk_to_rgb`, tetralinear over the +/// 16 measured ink corners of the CMYK cube), NOT the naive additive-then-clamp +/// `R = 1 - min(1, C+K)`. The additive form treats the inks as pure subtractive +/// primaries and so renders 100% K as `#000000` and 100% cyan as `#00FFFF`; +/// DeviceCMYK is a *device* space, so the colour is what the inks look like - +/// 100% K is `#231F20`, 100% cyan `#00ADEF`. This is the same conversion the +/// text/vector paths already use, so a DeviceCMYK image now matches the colour +/// of DeviceCMYK text and fills on the same page. For `/ICCBased` CMYK a real +/// CMM (qcms / lcms2) still takes precedence when a profile is available; this +/// is the no-profile fallback. pub(crate) fn cmyk_pixel_to_rgb(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] { - let c = c as f32 / 255.0; - let m = m as f32 / 255.0; - let y = y as f32 / 255.0; - let k = k as f32 / 255.0; - - let r = ((1.0 - (c + k).min(1.0)) * 255.0).round() as u8; - let g = ((1.0 - (m + k).min(1.0)) * 255.0).round() as u8; - let b = ((1.0 - (y + k).min(1.0)) * 255.0).round() as u8; - - [r, g, b] + let (r, g, b) = crate::color::cmyk_to_rgb( + c as f32 / 255.0, + m as f32 / 255.0, + y as f32 / 255.0, + k as f32 / 255.0, + ); + [ + (r * 255.0).round() as u8, + (g * 255.0).round() as u8, + (b * 255.0).round() as u8, + ] } /// Extract `/WhitePoint` from a Lab colour-space PDF object. @@ -1996,6 +1996,17 @@ mod indexed_tests { assert_eq!(out, expected.to_vec()); } + #[test] + fn cmyk_pixel_uses_process_inks_not_additive() { + // DeviceCMYK images convert via the process-ink corners (matching the + // text/vector paths), NOT the naive additive `1 - min(1, C+K)`. 100% K is + // the K ink #231F20 (not #000000); process cyan is #00ADEF (not #00FFFF). + assert_eq!(cmyk_pixel_to_rgb(0, 0, 0, 255), [0x23, 0x1F, 0x20]); + assert_eq!(cmyk_pixel_to_rgb(255, 0, 0, 0), [0x00, 0xAD, 0xEF]); + // No ink at all is the paper. + assert_eq!(cmyk_pixel_to_rgb(0, 0, 0, 0), [255, 255, 255]); + } + #[test] fn expand_indexed_1bpc_with_row_padding() { // 2-entry palette, 5x2 image at 1 bpc. 5 bits → 1 byte per row (3 bits padding). From d503aeb32a8195d9559f7612bfed45d0528f9d69 Mon Sep 17 00:00:00 2001 From: "Anthony J. (Tony) Bufort" Date: Sun, 19 Jul 2026 08:58:54 -0700 Subject: [PATCH 4/5] feat(color): unify DeviceCMYK rendering on process inks (renderer + overprint) Completes the CMYK process-ink unification the maintainer asked for on #861. The renderer's DeviceCMYK->RGB display (page_renderer::cmyk_to_rgb) now delegates to the process-ink color::cmyk_to_rgb (tetralinear over the 16 measured ink corners) instead of additive 1-min(1,C+K), matching the text/extraction and image paths (100% K = #231F20, 100% cyan = #00ADEF). Its RGB->CMYK sidecar inverse (resolve_rgb_paint_to_cmyk) now uses a new process-ink separation color::rgb_to_cmyk (Newton inversion of the K=0 trilinear forward, per-paint) so the pure-RGB overprint round-trip stays consistent within the process gamut. SEMANTIC NOTE: process inks have a smaller gamut than sRGB, so an out-of-gamut sRGB paint under overprint gamut-compresses (can no longer round-trip exactly) - the intended, more physically-correct behaviour; the separation never returns worse than the additive complement it starts from. Gate: 8393 tests pass (incl. all overprint/CMYK/separation integration tests - no re-baselining needed; they assert CMYK plate values, unaffected), fmt + clippy clean. Adds a gamut-aware round-trip unit test + updates the 3 renderer cmyk unit tests to process-ink values. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Anthony J. (Tony) Bufort (cherry picked from commit 9390f2d89b52846d94910cd306e7c1f2f4ab8848) --- src/color/mod.rs | 118 +++++++++++++++++++++++++++++++++ src/rendering/page_renderer.rs | 39 ++++++----- 2 files changed, 140 insertions(+), 17 deletions(-) diff --git a/src/color/mod.rs b/src/color/mod.rs index 4376a5062..9e2ab8adb 100644 --- a/src/color/mod.rs +++ b/src/color/mod.rs @@ -609,10 +609,128 @@ pub fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (f32, f32, f32) { (acc[0].clamp(0.0, 1.0), acc[1].clamp(0.0, 1.0), acc[2].clamp(0.0, 1.0)) } +/// Solve the 3x3 linear system `a * x = b` by Cramer's rule. `None` when the +/// matrix is (near-)singular. +fn solve3(a: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> { + let det3 = |m: [[f32; 3]; 3]| { + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) + }; + let det = det3(a); + if det.abs() < 1e-9 { + return None; + } + let mut out = [0.0f32; 3]; + for i in 0..3 { + let mut m = a; + for r in 0..3 { + m[r][i] = b[r]; + } + out[i] = det3(m) / det; + } + Some(out) +} + +/// Process-ink DeviceRGB -> DeviceCMYK separation: the (approximate) inverse of +/// [`cmyk_to_rgb`]. +/// +/// Used to mirror a pure-RGB paint into the CMYK overprint sidecar so that +/// RGB -> CMYK -> RGB round-trips as closely as the process gamut allows +/// (ISO 32000-1 s11.7.4.3). Uses `K = 0` (no black generation) and solves for +/// the `(C, M, Y)` whose process-ink forward reproduces `(r, g, b)`, by Newton +/// iteration with a finite-difference Jacobian. +/// +/// The process gamut is SMALLER than sRGB, so an out-of-gamut RGB (e.g. sRGB +/// primary blue) cannot be reproduced by any CMY mix - it maps to the nearest +/// in-gamut CMY (each ink clamped to `0..=1`). Called ONCE PER PAINT (not per +/// pixel), so the handful of forward evaluations is negligible. It starts from, +/// and never returns worse than, the additive complement `(1-r, 1-g, 1-b, 0)` +/// (the legacy inverse), so it can only tighten the round-trip. +pub fn rgb_to_cmyk(r: f32, g: f32, b: f32) -> (f32, f32, f32, f32) { + let target = [r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]; + let fwd = |x: [f32; 3]| { + let (fr, fg, fb) = cmyk_to_rgb(x[0], x[1], x[2], 0.0); + [fr, fg, fb] + }; + let resid = |x: [f32; 3]| { + let f = fwd(x); + (target[0] - f[0]).abs() + (target[1] - f[1]).abs() + (target[2] - f[2]).abs() + }; + // Start from the additive complement: a good first guess AND the safe floor. + let mut x = [1.0 - target[0], 1.0 - target[1], 1.0 - target[2]]; + let mut best_x = x; + let mut best_r = resid(x); + const EPS: f32 = 1e-3; + for _ in 0..16 { + if best_r < 1.0 / 255.0 { + break; + } + let f0 = fwd(x); + let res = [target[0] - f0[0], target[1] - f0[1], target[2] - f0[2]]; + // Jacobian J[i][j] = d fwd_i / d x_j by one-sided finite differences, + // stepping inward when x_j is on the [0,1] boundary. + let mut j = [[0.0f32; 3]; 3]; + for c in 0..3 { + let step = if x[c] + EPS <= 1.0 { EPS } else { -EPS }; + let mut xp = x; + xp[c] += step; + let fp = fwd(xp); + for (row, jr) in j.iter_mut().enumerate() { + jr[c] = (fp[row] - f0[row]) / step; + } + } + let Some(delta) = solve3(j, res) else { break }; + for (k, xk) in x.iter_mut().enumerate() { + *xk = (*xk + delta[k]).clamp(0.0, 1.0); + } + let rr = resid(x); + if rr < best_r { + best_r = rr; + best_x = x; + } + } + (best_x[0], best_x[1], best_x[2], 0.0) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn rgb_to_cmyk_round_trips_in_gamut_and_stays_in_range() { + let q = |v: f32| (v * 255.0).round() as i32; + // Any colour reachable by a K=0 CMY mix must round-trip + // RGB -> CMYK -> RGB to within a couple of 8-bit steps. + for &(c0, m0, y0) in &[ + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + (0.5, 0.2, 0.0), + (0.3, 0.3, 0.3), + (0.8, 0.4, 0.1), + (1.0, 1.0, 1.0), + ] { + let (r, g, b) = cmyk_to_rgb(c0, m0, y0, 0.0); + let (c, m, y, k) = rgb_to_cmyk(r, g, b); + assert_eq!(k, 0.0, "separation uses K=0"); + let (rr, gg, bb) = cmyk_to_rgb(c, m, y, k); + for (got, want) in [(rr, r), (gg, g), (bb, b)] { + assert!( + (q(got) - q(want)).abs() <= 3, + "in-gamut round-trip off: cmy ({c0},{m0},{y0}) rgb ({r},{g},{b}) -> ({rr},{gg},{bb})" + ); + } + } + // Out-of-gamut sRGB primary blue cannot be reproduced by any process CMY + // mix; it must map to a valid in-range CMYK (nearest in-gamut), not diverge. + let (c, m, y, k) = rgb_to_cmyk(0.0, 0.0, 1.0); + for v in [c, m, y, k] { + assert!((0.0..=1.0).contains(&v), "out-of-gamut CMYK stays in range"); + } + } + #[test] fn cmyk_uses_process_inks_not_the_naive_complement() { let q = |v: f32| (v * 255.0).round() as u8; diff --git a/src/rendering/page_renderer.rs b/src/rendering/page_renderer.rs index b7c9ae7b6..e174189d9 100644 --- a/src/rendering/page_renderer.rs +++ b/src/rendering/page_renderer.rs @@ -6288,9 +6288,11 @@ impl PageRenderer { return (cmyk[0], cmyk[1], cmyk[2], cmyk[3]); } } - // §10.3.5 inverse for the qcms / no-CMM backends. K stays at 0 - // because the additive-clamp form `(C, M, Y) = (1-R, 1-G, 1-B)` - // does not encode ink-coverage in K. + // Process-ink separation for the qcms / no-CMM backends: the inverse of + // the tetralinear `crate::color::cmyk_to_rgb`, so a pure-RGB paint + // mirrored into the CMYK sidecar and composited back round-trips within + // the process gamut (an out-of-gamut sRGB paint gamut-compresses). K + // stays 0 (no black generation). Replaces the additive `(1-R,1-G,1-B)`. // // When the document catalog DECLARES an /OutputIntents array // but `output_intent_cmyk_profile()` returns `None`, the @@ -6314,7 +6316,7 @@ impl PageRenderer { ); self.k_zero_warning_emitted = true; } - (1.0 - r, 1.0 - g, 1.0 - b, 0.0) + crate::color::rgb_to_cmyk(r, g, b) } /// Mirror an RGB-source paint into the CMYK sidecar via §11.3.4 + @@ -9230,14 +9232,17 @@ fn pixmap_paint_for_image_blit( paint } -/// Convert DeviceCMYK (0.0–1.0) to DeviceRGB (0.0–1.0) per ISO 32000-1:2008 -/// §10.3.5. The additive-clamp formula `R = 1 − min(1, C+K)` is the -/// spec-mandated fallback when no ICC profile is available. +/// Convert DeviceCMYK (0.0-1.0) to DeviceRGB (0.0-1.0) using the PROCESS-INK +/// conversion (`crate::color::cmyk_to_rgb`, tetralinear over the 16 measured ink +/// corners), NOT the naive additive-clamp `R = 1 - min(1, C+K)`. This unifies +/// the renderer's DeviceCMYK display with the text/extraction and image paths so +/// the same CMYK value resolves to the same RGB everywhere (100% K is `#231F20`, +/// 100% cyan `#00ADEF`). The RGB->CMYK sidecar inverse is +/// `crate::color::rgb_to_cmyk`, which keeps the overprint round-trip consistent +/// within the process gamut. A real ICC/OutputIntent CMM still takes precedence +/// when a profile is available. fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (f32, f32, f32) { - let r = 1.0 - (c + k).min(1.0); - let g = 1.0 - (m + k).min(1.0); - let b = 1.0 - (y + k).min(1.0); - (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)) + crate::color::cmyk_to_rgb(c, m, y, k) } /// Parse a colour-key `/Mask` array (ISO 32000-1 §8.9.6.4) into per-component @@ -9890,18 +9895,18 @@ mod tests { #[test] fn test_cmyk_to_rgb_black() { + // Process inks (not additive): 100% K is the K ink #231F20, NOT #000000. let (r, g, b) = cmyk_to_rgb(0.0, 0.0, 0.0, 1.0); - assert!((r - 0.0).abs() < 0.001); - assert!((g - 0.0).abs() < 0.001); - assert!((b - 0.0).abs() < 0.001); + let q = |v: f32| (v * 255.0).round() as u8; + assert_eq!([q(r), q(g), q(b)], [0x23, 0x1F, 0x20]); } #[test] fn test_cmyk_to_rgb_pure_cyan() { + // Process inks (not additive): 100% cyan is #00ADEF, NOT #00FFFF. let (r, g, b) = cmyk_to_rgb(1.0, 0.0, 0.0, 0.0); - assert!((r - 0.0).abs() < 0.001); - assert!((g - 1.0).abs() < 0.001); - assert!((b - 1.0).abs() < 0.001); + let q = |v: f32| (v * 255.0).round() as u8; + assert_eq!([q(r), q(g), q(b)], [0x00, 0xAD, 0xEF]); } #[test] From fe6ad1e9c2c483a3b70a77bcde416afacd9d6d46 Mon Sep 17 00:00:00 2001 From: "Anthony J. (Tony) Bufort" Date: Mon, 20 Jul 2026 02:04:11 -0700 Subject: [PATCH 5/5] fix(render): unify the composite DeviceCMYK path on process inks The extraction, image and page_renderer::cmyk_to_rgb paths were unified on the process-ink converter, but the LIVE composite render path was missed: run_pipeline_for_logical projects a resolved Cmyk via resolution::color::cmyk_to_rgb_via_intent, whose no-OutputIntent fallback was still the additive clamp 1 - min(1, C+K). So DeviceCMYK vector fills and k-coloured text rendered byte-identical to main (0 0 0 1 k -> pure black instead of the K-ink #231F20), and a broken-but-present OutputIntent produced process inks while no OutputIntent produced additive - a colour that depended on whether a useless OutputIntent was declared. - resolution/color.rs::cmyk_to_rgb now delegates to crate::color::cmyk_to_rgb (tetralinear over the 16 measured ink corners), so the composite fallback agrees with the renderer, image and extraction paths. The no-intent fallback in cmyk_to_rgb_via_intent inherits it. - Updated the resolver + pipeline unit tests to process-ink values (renamed resolves_device_cmyk_via_additive_clamp -> resolves_device_cmyk_via_process_inks; the icc no-CMM fallback test now agrees with the process-ink no-OutputIntent arm). - Added a render-level integration test (tests/test_render_cmyk_process_inks.rs) that rasterises a DeviceCMYK vector-swatch + k-text page and pins the process-ink output (#00ADEF cyan, #231F20 K, no pure-black pixel) - the e2e pin that would have caught this. - Re-baselined the composite CMYK byte-exact pins across the wave1-5 QA, OutputIntent-fallback and transparency-flattening suites to process inks. Note: an out-of-gamut sRGB paint under /OP now gamut-compresses through the paired process-ink round-trip on the composite path (adversarial_rgb_overprint_paint_gamut_compresses_no_panic) - the physically-correct consequence of the unification. Gate: cargo test --features icc-qcms,rendering -> 8965 pass, 0 fail; fmt + clippy --all-targets --features icc-qcms,rendering clean. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Anthony J. (Tony) Bufort --- src/rendering/page_renderer.rs | 30 ++-- src/rendering/resolution/color.rs | 122 ++++++++----- src/rendering/resolution/pipeline.rs | 17 +- tests/test_render_cmyk_process_inks.rs | 169 +++++++++++++++++ tests/test_render_output_intent.rs | 170 ++++++++++-------- ...est_render_resolution_pipeline_qa_wave1.rs | 16 +- ...est_render_resolution_pipeline_qa_wave2.rs | 9 +- ...est_render_resolution_pipeline_qa_wave3.rs | 22 +-- ...est_render_resolution_pipeline_qa_wave4.rs | 20 +-- ...est_render_resolution_pipeline_qa_wave5.rs | 40 +++-- ...est_transparency_flattening_adversarial.rs | 55 +++--- tests/test_transparency_flattening_audit.rs | 73 ++++---- 12 files changed, 478 insertions(+), 265 deletions(-) create mode 100644 tests/test_render_cmyk_process_inks.rs diff --git a/src/rendering/page_renderer.rs b/src/rendering/page_renderer.rs index e174189d9..8f38d1f8c 100644 --- a/src/rendering/page_renderer.rs +++ b/src/rendering/page_renderer.rs @@ -7847,7 +7847,7 @@ impl PageRenderer { // backend has the channel decomposition. Project to RGBA // via the context-aware CMYK→RGB path: consult the // document's /OutputIntents CMYK profile when present, fall - // back to §10.3.5 additive-clamp otherwise. + // back to the process-ink conversion otherwise. ResolvedColor::Cmyk { c, m, y, k, a } => { let (r, g, b) = crate::rendering::resolution::color::cmyk_to_rgb_via_intent(c, m, y, k, &ctx); @@ -10119,9 +10119,11 @@ mod tests { .expect("Tr=1 stroke side must produce ResolvedColors"); let (r, g, b, _a) = colors.stroke.expect("Tr=1 must populate the stroke side"); + // Process-ink magenta corner #EC008C = (0.9255, 0, 0.5490); the + // legacy 1-tint=0 fallback would put black on the stroke channel. assert!( - r > 0.78 && g < 0.24 && b > 0.78, - "stroke side must be magenta (Type-4 evaluated), \ + (r - 0.9255).abs() < 0.02 && g < 0.02 && (b - 0.5490).abs() < 0.02, + "stroke side must be process-ink magenta (Type-4 evaluated), \ not the legacy 1-tint=0 black; got ({r}, {g}, {b})" ); // The fill channel must not have been resolved — the helper @@ -10206,9 +10208,10 @@ mod tests { .expect("Type 4 Separation fill must splice through ImageMask variant"); let (r, g, b) = spliced.fill_color_rgb; + // Process-ink magenta corner #EC008C = (0.9255, 0, 0.5490). assert!( - r > 0.78 && g < 0.24 && b > 0.78, - "ImageMask fill must be magenta (Type 4 evaluated), not legacy black; got ({r}, {g}, {b})" + (r - 0.9255).abs() < 0.02 && g < 0.02 && (b - 0.5490).abs() < 0.02, + "ImageMask fill must be process-ink magenta (Type 4 evaluated), not legacy black; got ({r}, {g}, {b})" ); // Stroke side must remain untouched — the variant is fill-only. assert_eq!( @@ -10287,11 +10290,12 @@ mod tests { .expect("Type 4 Separation full-tint must resolve to Some(rgba)"); let (r, g, b, a) = rgba; assert!( - (r - 1.0).abs() < 1.0e-3 + (r - 0.9255).abs() < 1.0e-3 && g.abs() < 1.0e-3 - && (b - 1.0).abs() < 1.0e-3 + && (b - 0.5490).abs() < 1.0e-3 && (a - 1.0).abs() < 1.0e-3, - "Type 4 Separation at tint=1 must produce magenta RGBA (≈1, 0, 1, 1); got ({r}, {g}, {b}, {a})" + "Type 4 Separation at tint=1 must produce process-ink magenta RGBA \ + (#EC008C ≈ 0.9255, 0, 0.5490, 1); got ({r}, {g}, {b}, {a})" ); } @@ -10334,9 +10338,9 @@ mod tests { "DeviceGray must expand the single component to (g, g, g); got ({r}, {g}, {b})" ); - // DeviceCMYK: additive-clamp conversion `(1-c-k, 1-m-k, - // 1-y-k)` with clamping to [0, 1]. Pure cyan (1, 0, 0, 0) - // → RGB(0, 1, 1). + // DeviceCMYK: process-ink conversion (tetralinear over the 16 + // measured ink corners). Pure cyan (1, 0, 0, 0) lands on the + // measured cyan corner #00ADEF = (0, 0.6784, 0.9373). let cmyk_space = Object::Name("DeviceCMYK".to_string()); let rgba = renderer .pipeline_resolve_components( @@ -10349,8 +10353,8 @@ mod tests { .expect("DeviceCMYK must resolve"); let (r, g, b, _a) = rgba; assert!( - r.abs() < 1.0e-3 && (g - 1.0).abs() < 1.0e-3 && (b - 1.0).abs() < 1.0e-3, - "DeviceCMYK pure cyan must map to (0, 1, 1) under additive clamp; got ({r}, {g}, {b})" + r.abs() < 1.0e-3 && (g - 0.6784).abs() < 1.0e-3 && (b - 0.9373).abs() < 1.0e-3, + "DeviceCMYK pure cyan must map to process-ink #00ADEF (0, 0.6784, 0.9373); got ({r}, {g}, {b})" ); } diff --git a/src/rendering/resolution/color.rs b/src/rendering/resolution/color.rs index 694fda48e..2ff62a1aa 100644 --- a/src/rendering/resolution/color.rs +++ b/src/rendering/resolution/color.rs @@ -13,7 +13,7 @@ //! and `DeviceGray` alternates as well. //! - **ICCBased** colour spaces. The resolver delegates to the //! [`crate::color::Transform`] CMM when the `icc` feature is on and falls -//! back to the §10.3.5 additive-clamp formula otherwise. This is the same +//! back to the process-ink conversion otherwise. This is the same //! path image extraction uses, so we re-use [`crate::color`] rather than //! carrying a second copy of the conversion code. //! - **Indexed** colour spaces. The resolver follows the index into the base @@ -125,7 +125,7 @@ impl ColorResolver { // render). Return None so the caller falls through to the // device-family path (`device_to_rgba`), which routes CMYK // through `cmyk_to_rgb_via_intent` and so consults - // `/OutputIntents` when present, or §10.3.5 additive-clamp + // `/OutputIntents` when present, or the process-ink conversion // when not. if space.as_name().is_none() && space.as_array().is_none() { return Ok(None); @@ -615,7 +615,7 @@ fn three_as_rgb(components: &[f32], alpha: f32) -> ResolvedColor { /// Emit `ResolvedColor::Rgba` from a 4-component CMYK via the /// context-aware CMYK→RGB path: the document's `/OutputIntents` CMYK -/// profile when present, otherwise §10.3.5 additive-clamp. Used by +/// profile when present, otherwise the process-ink conversion. Used by /// the Separation / DeviceN alternate-CMYK projection — the per-plate /// routing for those sources is governed by the source colour space, /// not the alternate's CMYK decomposition, so the alt is composite- @@ -630,7 +630,7 @@ fn four_as_cmyk(components: &[f32], alpha: f32, ctx: &ResolutionContext) -> Reso /// for genuine DeviceCMYK / ICCBased N=4 sources. The per-plate /// router consumes this directly (process-ink routing + OPM=1 zero- /// component rule); the composite path projects to RGBA via the -/// §10.3.5 additive-clamp formula in `run_pipeline_for_logical`. +/// process-ink `cmyk_to_rgb_via_intent` in `run_pipeline_for_logical`. fn four_as_cmyk_native(components: &[f32], alpha: f32) -> ResolvedColor { ResolvedColor::Cmyk { c: components[0].clamp(0.0, 1.0), @@ -641,17 +641,20 @@ fn four_as_cmyk_native(components: &[f32], alpha: f32) -> ResolvedColor { } } -/// ISO 32000-1:2008 §10.3.5 additive-clamp DeviceCMYK → DeviceRGB. +/// DeviceCMYK → DeviceRGB via the PROCESS-INK conversion +/// (`crate::color::cmyk_to_rgb`, tetralinear over the 16 measured ink +/// corners), NOT the naive §10.3.5 additive clamp `R = 1 - min(1, C+K)`. /// -/// Mirrors the helper in `page_renderer.rs:2555`. We duplicate it here -/// deliberately so the resolver has no compile-time dependency on the -/// existing renderer; a follow-up will collapse the two callers onto a -/// single shared helper as part of the renderer-migration work. +/// This is the no-OutputIntent fallback of the composite render path +/// (`run_pipeline_for_logical` → `cmyk_to_rgb_via_intent`), so it must +/// agree with the renderer's own `page_renderer::cmyk_to_rgb`, the image +/// pixel path (`extractors::images::cmyk_pixel_to_rgb`) and the +/// text/extraction path (`document.rs`/`text.rs`): the same CMYK value +/// resolves to the same RGB everywhere (100% K is `#231F20`, 100% cyan +/// `#00ADEF`). A real ICC/OutputIntent CMM still takes precedence when a +/// profile is available (see `cmyk_to_rgb_via_intent`). fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (f32, f32, f32) { - let r = 1.0 - (c + k).min(1.0); - let g = 1.0 - (m + k).min(1.0); - let b = 1.0 - (y + k).min(1.0); - (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)) + crate::color::cmyk_to_rgb(c, m, y, k) } /// Context-aware CMYK → RGB convergence. @@ -675,9 +678,10 @@ fn cmyk_to_rgb(c: f32, m: f32, y: f32, k: f32) -> (f32, f32, f32) { /// /// 2. `ctx.output_intent_cmyk` is `None` — the document didn't /// declare a CMYK OutputIntent (or one is present but couldn't be -/// parsed). Falls through to the spec's §10.3.5 additive-clamp -/// formula. This is the byte-for-byte fallback the renderer -/// shipped before OutputIntent threading landed. +/// parsed). Falls through to the process-ink `cmyk_to_rgb` +/// (`crate::color::cmyk_to_rgb`), the same conversion the renderer, +/// image and extraction paths use, so a DeviceCMYK colour resolves +/// identically whether or not a broken OutputIntent is present. /// /// **Black-Point Compensation (BPC) and rendering-intent caveats:** /// qcms 0.3.0 does not implement BPC and, for CMYK sources, silently @@ -820,17 +824,18 @@ mod tests { /// Assert resolved colour matches expected RGBA. Accepts either /// `ResolvedColor::Rgba` directly or `ResolvedColor::Cmyk` - /// projected via the §10.3.5 additive-clamp formula (the resolver - /// now emits Cmyk for Separation / DeviceN sources with a CMYK - /// alternate so per-plate backends see the channel decomposition; - /// composite consumers project on demand). + /// projected via the same process-ink `cmyk_to_rgb` the composite + /// render path uses (the resolver now emits Cmyk for Separation / + /// DeviceN sources with a CMYK alternate so per-plate backends see + /// the channel decomposition; composite consumers project on + /// demand). Projecting through the engine's own converter keeps the + /// expected RGB in this helper consistent with what the renderer + /// actually paints for the same CMYK plates. fn assert_rgba(c: ResolvedColor, r: f32, g: f32, b: f32, a: f32) { let (rr, gg, bb, aa) = match c { ResolvedColor::Rgba { r, g, b, a } => (r, g, b, a), ResolvedColor::Cmyk { c, m, y, k, a } => { - let rr = (1.0 - (c + k).min(1.0)).clamp(0.0, 1.0); - let gg = (1.0 - (m + k).min(1.0)).clamp(0.0, 1.0); - let bb = (1.0 - (y + k).min(1.0)).clamp(0.0, 1.0); + let (rr, gg, bb) = super::cmyk_to_rgb(c, m, y, k); (rr, gg, bb, a) }, other => panic!("expected Rgba or Cmyk; got {other:?}"), @@ -862,14 +867,28 @@ mod tests { } #[test] - fn resolves_device_cmyk_via_additive_clamp() { - // CMYK(1,0,0,0) → RGB(0,1,1) per §10.3.5. + fn resolves_device_cmyk_via_process_inks() { + // DeviceCMYK composites through the process-ink converter + // (`crate::color::cmyk_to_rgb`, tetralinear over the 16 measured + // ink corners), NOT the §10.3.5 additive clamp. 100% cyan lands + // on the measured corner `#00ADEF` = (0.0, 0.6784, 0.9373), not + // (0, 1, 1). The resolver emits `Cmyk` (for per-plate routing); + // the composite projection is `cmyk_to_rgb_via_intent`, whose + // no-OutputIntent fallback is the process-ink path. let doc = fixture_doc(); let spaces = HashMap::new(); let resolver = ColorResolver::new(); let lc = LogicalColor::Device(DeviceColor::Cmyk(1.0, 0.0, 0.0, 0.0)); let c = resolver.resolve(&lc, &ctx(&doc, &spaces), 1.0).unwrap(); - assert_rgba(c, 0.0, 1.0, 1.0, 1.0); + let (cc, m, y, k, a) = match c { + ResolvedColor::Cmyk { c, m, y, k, a } => (c, m, y, k, a), + other => panic!("expected Cmyk; got {other:?}"), + }; + let (r, g, b) = super::cmyk_to_rgb_via_intent(cc, m, y, k, &ctx(&doc, &spaces)); + assert!((r - 0.0).abs() < 1e-3, "r: got {r}, want 0.0"); + assert!((g - 0.6784).abs() < 1e-3, "g: got {g}, want 0.6784"); + assert!((b - 0.9373).abs() < 1e-3, "b: got {b}, want 0.9373"); + assert!((a - 1.0).abs() < 1e-3, "a: got {a}, want 1.0"); } #[test] @@ -890,7 +909,8 @@ mod tests { fn separation_with_type2_cmyk_alternate_uses_function() { // /Separation /SpotInk /DeviceCMYK // << /FunctionType 2 /N 1 /C0 [0 0 0 0] /C1 [0 1 0 0] /Domain [0 1] /Range [0 1 0 1 0 1 0 1] >> - // tint=1 must produce CMYK(0,1,0,0) → RGB(1,0,1) (magenta). + // tint=1 must produce CMYK(0,1,0,0), the process-ink magenta + // corner #EC008C = (0.9255, 0, 0.5490). let mut func_dict: HashMap = HashMap::new(); func_dict.insert("FunctionType".into(), Object::Integer(2)); func_dict.insert("N".into(), Object::Integer(1)); @@ -929,8 +949,8 @@ mod tests { components: smallvec::smallvec![1.0], }; let c = resolver.resolve(&lc, &ctx(&doc, &spaces), 1.0).unwrap(); - // CMYK(0,1,0,0) → R=1-0=1, G=1-1=0, B=1-0=1 - assert_rgba(c, 1.0, 0.0, 1.0, 1.0); + // CMYK(0,1,0,0) -> process-ink magenta corner (0.9255, 0, 0.5490) + assert_rgba(c, 0.9255, 0.0, 0.5490, 1.0); } #[test] @@ -1004,7 +1024,7 @@ mod tests { components: smallvec::smallvec![1.0], }; let c = resolver.resolve(&lc, &ctx(&doc, &spaces), 1.0).unwrap(); - assert_rgba(c, 1.0, 0.0, 1.0, 1.0); + assert_rgba(c, 0.9255, 0.0, 0.5490, 1.0); } #[test] @@ -1143,7 +1163,9 @@ mod tests { components: smallvec::smallvec![1.0, 0.0, 0.0, 0.0], }; let c = resolver.resolve(&lc, &ctx(&doc, &spaces), 1.0).unwrap(); - assert_rgba(c, 0.0, 1.0, 1.0, 1.0); + // ICCBased N=4 falls back to DeviceCMYK; CMYK(1,0,0,0) composites + // through the process-ink converter to the cyan corner #00ADEF. + assert_rgba(c, 0.0, 0.6784, 0.9373, 1.0); } #[test] @@ -1163,19 +1185,20 @@ mod tests { } #[test] - fn cmyk_to_rgb_via_intent_with_no_output_intent_matches_additive_clamp() { - // The fallback arm is the spec's §10.3.5 formula. Pin one - // representative quadruple byte-exact so a regression that - // re-routed the no-OutputIntent path through some other - // conversion would surface here. + fn cmyk_to_rgb_via_intent_with_no_output_intent_uses_process_inks() { + // The fallback arm is the process-ink `cmyk_to_rgb`. Pin one + // representative quadruple so a regression that re-routed the + // no-OutputIntent path through some other conversion (e.g. back + // to the §10.3.5 additive clamp) would surface here. CMYK(0.25, + // 0, 0, 0) interpolates 0.75·paper + 0.25·cyan corner = + // (0.75, 0.9196, 0.9843). let doc = fixture_doc(); let spaces = HashMap::new(); let ctx = ResolutionContext::new(&doc, &spaces); - // CMYK(0.25, 0, 0, 0) → R=0.75, G=1.0, B=1.0. let (r, g, b) = super::cmyk_to_rgb_via_intent(0.25, 0.0, 0.0, 0.0, &ctx); - assert!((r - 0.75).abs() < 1e-6); - assert!((g - 1.0).abs() < 1e-6); - assert!((b - 1.0).abs() < 1e-6); + assert!((r - 0.75).abs() < 1e-4, "r: got {r}, want 0.75"); + assert!((g - 0.9196).abs() < 1e-4, "g: got {g}, want 0.9196"); + assert!((b - 0.9843).abs() < 1e-4, "b: got {b}, want 0.9843"); } #[cfg(any(feature = "icc-qcms", feature = "icc-lcms2"))] @@ -1184,7 +1207,7 @@ mod tests { // The header-only stub profile parses (IccProfile::parse accepts // the 128-byte header) but qcms refuses to build a Transform // from it because there's no tag table. The wrapper devolves to - // §10.3.5 internally — the helper must agree byte-for-byte with + // its no-CMM fallback internally — the helper must agree with // the no-OutputIntent path on the same input. This is the // shape a real but malformed /OutputIntents profile would take. let doc = fixture_doc(); @@ -1200,14 +1223,15 @@ mod tests { ); let ctx = ResolutionContext::new(&doc, &spaces).with_output_intent(Some(&profile)); let (r, g, b) = super::cmyk_to_rgb_via_intent(0.25, 0.0, 0.0, 0.0, &ctx); - // HONEST_GAP: this byte-exact agreement depends on - // crate::color::Transform::convert_cmyk_pixel matching - // crate::extractors::images::cmyk_pixel_to_rgb on the §10.3.5 - // path. If those two diverge in the future the helper here - // could disagree with the no-OutputIntent arm even though - // both intended to run the spec fallback. + // The no-CMM fallback of `convert_cmyk_pixel` routes through + // `crate::extractors::images::cmyk_pixel_to_rgb`, which is now + // the process-ink `crate::color::cmyk_to_rgb` — the same + // conversion the no-OutputIntent arm takes. So both arms agree + // on the process-ink value for CMYK(0.25,0,0,0) ≈ + // (0.75, 0.9196, 0.9843); the 8-bit CMM round-trip widens the + // tolerance slightly. assert!((r - 0.75).abs() < 0.01, "got r={r}"); - assert!((g - 1.0).abs() < 0.01, "got g={g}"); - assert!((b - 1.0).abs() < 0.01, "got b={b}"); + assert!((g - 0.9196).abs() < 0.01, "got g={g}"); + assert!((b - 0.9843).abs() < 0.01, "got b={b}"); } } diff --git a/src/rendering/resolution/pipeline.rs b/src/rendering/resolution/pipeline.rs index dde254ecb..0d75fc690 100644 --- a/src/rendering/resolution/pipeline.rs +++ b/src/rendering/resolution/pipeline.rs @@ -499,20 +499,21 @@ mod tests { let cmd = pipeline.resolve(&intent, &ctx, None).unwrap(); // Separation with a DeviceCMYK alternate now emits Cmyk so the // per-plate router has the channel decomposition. Project to - // RGBA here and pin the expected magenta. + // RGBA through the same composite converter the renderer uses + // (process-ink `cmyk_to_rgb_via_intent`) and pin the process-ink + // magenta corner #EC008C = (0.9255, 0, 0.5490). let (r, g, b, a) = match cmd.color { ResolvedColor::Rgba { r, g, b, a } => (r, g, b, a), ResolvedColor::Cmyk { c, m, y, k, a } => { - let rr = (1.0 - (c + k).min(1.0)).clamp(0.0, 1.0); - let gg = (1.0 - (m + k).min(1.0)).clamp(0.0, 1.0); - let bb = (1.0 - (y + k).min(1.0)).clamp(0.0, 1.0); + let (rr, gg, bb) = + crate::rendering::resolution::color::cmyk_to_rgb_via_intent(c, m, y, k, &ctx); (rr, gg, bb, a) }, other => panic!("expected Rgba or Cmyk; got {other:?}"), }; - assert!((r - 1.0).abs() < 1e-3); - assert!((g - 0.0).abs() < 1e-3); - assert!((b - 1.0).abs() < 1e-3); - assert!((a - 1.0).abs() < 1e-3); + assert!((r - 0.9255).abs() < 1e-3, "r={r}"); + assert!((g - 0.0).abs() < 1e-3, "g={g}"); + assert!((b - 0.5490).abs() < 1e-3, "b={b}"); + assert!((a - 1.0).abs() < 1e-3, "a={a}"); } } diff --git a/tests/test_render_cmyk_process_inks.rs b/tests/test_render_cmyk_process_inks.rs new file mode 100644 index 000000000..7e3f2b946 --- /dev/null +++ b/tests/test_render_cmyk_process_inks.rs @@ -0,0 +1,169 @@ +//! Render-level pin for the DeviceCMYK composite path (no OutputIntent). +//! +//! Rasterises a page that paints DeviceCMYK **vector swatches** and +//! **`k`-coloured text** through the live composite renderer +//! (`run_pipeline_for_logical` -> `cmyk_to_rgb_via_intent`), with **no** +//! `/OutputIntents` profile declared, so the conversion takes the +//! process-ink fallback (`crate::color::cmyk_to_rgb`) rather than the +//! ISO 32000-1:2008 §10.3.5 additive clamp `R = 1 - min(1, C+K)`. +//! +//! This is the end-to-end gap that the extraction/image-path unification +//! left open: the vector/text composite render used to stay on the +//! additive clamp, so a `0 0 0 1 k` fill rendered pure black `(0,0,0)` +//! instead of the K-ink `#231F20`. These assertions fail loudly on the +//! additive path and pass only when the composite render is on process +//! inks. + +#![cfg(feature = "rendering")] + +use pdf_oxide::document::PdfDocument; +use pdf_oxide::rendering::{render_page, ImageFormat, RenderOptions}; + +/// Build a 100x100 pt page with two DeviceCMYK vector swatches and a +/// `k`-coloured text run, all via the `k` (fill CMYK) operator: +/// - cyan swatch `1 0 0 0 k` at pdf (10,60)-(40,90) -> image centre (25,25) +/// - K swatch `0 0 0 1 k` at pdf (60,60)-(90,90) -> image centre (75,25) +/// - K text `0 0 0 1 k (FW) Tj` baseline pdf (15,30) -> image rows ~53..70 +fn build_cmyk_swatch_and_text_pdf() -> Vec { + let content = "1 0 0 0 k\n\ + 10 60 30 30 re f\n\ + 0 0 0 1 k\n\ + 60 60 30 30 re f\n\ + BT\n/F1 24 Tf\n0 0 0 1 k\n15 30 Td\n(FW) Tj\nET\n"; + let content_bytes = content.as_bytes(); + + let mut buf = Vec::new(); + let mut offsets = Vec::new(); + + buf.extend_from_slice(b"%PDF-1.4\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + + offsets.push(buf.len()); + buf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"); + + offsets.push(buf.len()); + buf.extend_from_slice( + b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] \ + /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n", + ); + + offsets.push(buf.len()); + let stream_header = format!("4 0 obj\n<< /Length {} >>\nstream\n", content_bytes.len()); + buf.extend_from_slice(stream_header.as_bytes()); + buf.extend_from_slice(content_bytes); + buf.extend_from_slice(b"\nendstream\nendobj\n"); + + offsets.push(buf.len()); + buf.extend_from_slice( + b"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n", + ); + + let xref_offset = buf.len(); + buf.extend_from_slice(b"xref\n"); + buf.extend_from_slice(format!("0 {}\n", offsets.len() + 1).as_bytes()); + buf.extend_from_slice(b"0000000000 65535 f \n"); + for offset in &offsets { + buf.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes()); + } + buf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n", + offsets.len() + 1, + xref_offset + ) + .as_bytes(), + ); + + buf +} + +fn render_rgba_100(doc: &PdfDocument) -> Vec { + let opts = RenderOptions::with_dpi(72).as_raw(); + let img = render_page(doc, 0, &opts).expect("render_page"); + assert_eq!(img.format, ImageFormat::RawRgba8); + assert_eq!(img.data.len(), 100 * 100 * 4, "expected a 100x100 RGBA raster"); + img.data +} + +fn pixel_at(rgba: &[u8], x: u32, y: u32) -> (u8, u8, u8, u8) { + let off = ((y * 100 + x) * 4) as usize; + (rgba[off], rgba[off + 1], rgba[off + 2], rgba[off + 3]) +} + +/// `|a - b| <= tol` on each channel. +fn near(got: (u8, u8, u8), want: (u8, u8, u8), tol: i32) -> bool { + let d = |a: u8, b: u8| (a as i32 - b as i32).abs(); + d(got.0, want.0) <= tol && d(got.1, want.1) <= tol && d(got.2, want.2) <= tol +} + +#[test] +fn device_cmyk_vector_and_text_render_via_process_inks_not_additive_clamp() { + let pdf = build_cmyk_swatch_and_text_pdf(); + let doc = PdfDocument::from_bytes(pdf).expect("parse PDF"); + let rgba = render_rgba_100(&doc); + + // --- Cyan vector swatch: 100% cyan is the measured corner #00ADEF + // = (0, 173, 239), NOT the additive-clamp (0, 255, 255). --- + let cyan = pixel_at(&rgba, 25, 25); + assert_eq!(cyan.3, 255, "cyan swatch centre should be opaque"); + assert!( + near((cyan.0, cyan.1, cyan.2), (0, 173, 239), 3), + "cyan swatch should be process-ink #00ADEF, got {cyan:?}" + ); + assert_ne!( + (cyan.0, cyan.1, cyan.2), + (0, 255, 255), + "cyan swatch must NOT be the additive-clamp (0,255,255)" + ); + + // --- K vector swatch: 100% K is the measured corner #231F20 + // = (35, 31, 32), NOT the additive-clamp pure black (0, 0, 0). --- + let k = pixel_at(&rgba, 75, 25); + assert_eq!(k.3, 255, "K swatch centre should be opaque"); + assert!( + near((k.0, k.1, k.2), (35, 31, 32), 3), + "K swatch should be process-ink #231F20, got {k:?}" + ); + assert_ne!( + (k.0, k.1, k.2), + (0, 0, 0), + "K swatch must NOT be additive-clamp pure black (0,0,0)" + ); + + // --- K-coloured text: the fully-inked interior of a glyph stroke must + // land on the K-ink #231F20, not pure black. Scan the text band + // for the darkest pixel and require it to match the K-ink. --- + let mut darkest: Option<(u8, u8, u8)> = None; + for y in 48..74 { + for x in 12..58 { + let (r, g, b, a) = pixel_at(&rgba, x, y); + if a == 0 { + continue; + } + let sum = r as u32 + g as u32 + b as u32; + if darkest.is_none_or(|(dr, dg, db)| sum < dr as u32 + dg as u32 + db as u32) { + darkest = Some((r, g, b)); + } + } + } + let darkest = darkest.expect("expected inked text pixels in the text band"); + assert!( + near(darkest, (35, 31, 32), 8), + "darkest K-text pixel should be process-ink #231F20, got {darkest:?} \ + (additive clamp would paint it pure black)" + ); + + // --- Global discriminator: with the process-ink composite live, no + // mark on the page is pure black. The additive clamp would fill + // both the K swatch and the K text with (0,0,0) pixels. --- + let pure_black = rgba + .chunks_exact(4) + .filter(|px| px[3] > 0 && px[0] == 0 && px[1] == 0 && px[2] == 0) + .count(); + assert_eq!( + pure_black, 0, + "process-ink composite must not emit any pure-black (0,0,0) pixel; found {pure_black}" + ); +} diff --git a/tests/test_render_output_intent.rs b/tests/test_render_output_intent.rs index 3f799a065..4c65ece7a 100644 --- a/tests/test_render_output_intent.rs +++ b/tests/test_render_output_intent.rs @@ -981,6 +981,30 @@ fn pixel_at(rgba: &[u8], x: u32, y: u32) -> (u8, u8, u8, u8) { (rgba[off], rgba[off + 1], rgba[off + 2], rgba[off + 3]) } +/// Expected 8-bit RGB for a DeviceCMYK quadruple under the **process-ink** +/// converter (`pdf_oxide::color::cmyk_to_rgb`, tetralinear over the 16 +/// measured ink corners) - the conversion the composite renderer now +/// takes when there is no usable `/OutputIntents` CMYK profile. +/// +/// This replaces the old ISO 32000-1:2008 §10.3.5 additive-clamp +/// `1 - min(1, C+K)` fallback: an identical CMYK value now resolves the +/// same whether it is a vector fill, a raster image, or extracted text. +/// The opaque-vector fill path rounds each channel `round(v * 255)` (the +/// same rounding the prior byte-exact additive pins relied on), so this +/// helper is byte-for-byte what the renderer must emit for a fully +/// covered opaque CMYK fill. +fn process_ink_rgb(c: f32, m: f32, y: f32, k: f32) -> (u8, u8, u8) { + let (r, g, b) = pdf_oxide::color::cmyk_to_rgb(c, m, y, k); + ((r * 255.0).round() as u8, (g * 255.0).round() as u8, (b * 255.0).round() as u8) +} + +/// [`process_ink_rgb`] plus a fully-opaque alpha, for the `pixel_at` +/// 4-tuple comparisons. +fn process_ink_rgba(c: f32, m: f32, y: f32, k: f32) -> (u8, u8, u8, u8) { + let (r, g, b) = process_ink_rgb(c, m, y, k); + (r, g, b, 255) +} + // =========================================================================== // Phase 2 positive test // =========================================================================== @@ -1079,7 +1103,7 @@ fn device_cmyk_paint_with_output_intent_renders_via_icc_not_additive_clamp() { /// some other ICC profile (or that flipped the precedence rules) would /// surface here as the wrong colour. #[test] -fn device_cmyk_paint_without_output_intent_renders_additive_clamp() { +fn device_cmyk_paint_without_output_intent_renders_process_inks() { let pdf = build_pdf_cmyk_without_output_intent(); let doc = PdfDocument::from_bytes(pdf).expect("open synthetic PDF"); // Cross-check the catalog has no OutputIntent — if it did, this @@ -1093,15 +1117,14 @@ fn device_cmyk_paint_without_output_intent_renders_additive_clamp() { let rgba = render_rgba(&doc); let (r, g, b, _a) = pixel_at(&rgba, 50, 50); - // CMYK(0.25, 0, 0, 0) → additive-clamp: - // R = 1 - (0.25 + 0) = 0.75 → 191 - // G = 1 - (0.00 + 0) = 1.00 → 255 - // B = 1 - (0.00 + 0) = 1.00 → 255 + // CMYK(0.25, 0, 0, 0) with no usable OutputIntent -> process-ink + // fallback: 0.75*paper + 0.25*cyan corner. Pin it to the canonical + // converter byte-for-byte (NOT the old additive-clamp (191, 255, 255)). assert_eq!( (r, g, b), - (191, 255, 255), - "without /OutputIntents the §10.3.5 additive-clamp fallback must \ - be preserved byte-for-byte; got ({r}, {g}, {b})" + process_ink_rgb(0.25, 0.0, 0.0, 0.0), + "without /OutputIntents the composite render must use the process-ink \ + fallback byte-for-byte; got ({r}, {g}, {b})" ); } @@ -1224,7 +1247,7 @@ fn output_intent_constant_clut_is_invariant_across_rendering_intents() { /// OutputIntent fixture produces, so a malformed `/OutputIntents` /// degrades gracefully. #[test] -fn output_intent_with_unparseable_profile_falls_through_to_additive_clamp() { +fn output_intent_with_unparseable_profile_falls_through_to_process_inks() { // Header-only profile: parses through `IccProfile::parse` (which // only validates the 128-byte header), but qcms refuses at build // time because there's no tag table. Mirrors the stub the in-source @@ -1253,9 +1276,9 @@ fn output_intent_with_unparseable_profile_falls_through_to_additive_clamp() { let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (191u8, 255, 255, 255), - "unparseable OutputIntent profile must fall through to §10.3.5 byte-exact; \ - got ({r},{g},{b},{a})" + process_ink_rgba(0.25, 0.0, 0.0, 0.0), + "unparseable OutputIntent profile must fall through to the process-ink converter \ + byte-exact; got ({r},{g},{b},{a})" ); } @@ -1298,13 +1321,13 @@ fn output_intent_with_mismatched_icc_header_colour_space_is_rejected_at_parse() let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (191u8, 255, 255, 255), - "mismatched-header OutputIntent must fall through to §10.3.5; got ({r},{g},{b},{a})" + process_ink_rgba(0.25, 0.0, 0.0, 0.0), + "mismatched-header OutputIntent must fall through to the process-ink converter; got ({r},{g},{b},{a})" ); } // =========================================================================== -// QA: helper-level consistency (§10.3.5 source-of-truth probe) +// QA: helper-level consistency (process-ink source-of-truth probe) // =========================================================================== /// Pin that `crate::extractors::images::cmyk_pixel_to_rgb` and the @@ -1315,15 +1338,15 @@ fn output_intent_with_mismatched_icc_header_colour_space_is_rejected_at_parse() /// `cmyk_to_rgb_via_intent_falls_back_when_profile_has_no_cmm`. Verified /// here at the public-API level by routing both paths through a known /// CMYK input and comparing byte-for-byte. If a future refactor diverges -/// the two §10.3.5 implementations, the fallback path inside qcms's +/// the two process-ink implementations, the fallback path inside qcms's /// no-CMM arm could disagree with the resolver's bare-fallback arm even -/// though both intend the spec formula. +/// though both intend the same conversion. /// /// The probe iterates over a handful of representative inputs — pure /// process inks, the test fixture's input, and a few interior CMYK /// quadruples. Every input must agree. #[test] -fn additive_clamp_consistency_between_extractors_helper_and_no_output_intent_arm() { +fn process_ink_consistency_between_extractors_helper_and_no_output_intent_arm() { use pdf_oxide::color::{IccProfile, RenderingIntent, Transform}; use std::sync::Arc; @@ -1340,18 +1363,22 @@ fn additive_clamp_consistency_between_extractors_helper_and_no_output_intent_arm let prof = Arc::new(IccProfile::parse(header_only, 4).expect("parse")); let t = Transform::new_srgb_target(prof, RenderingIntent::RelativeColorimetric); - // The §10.3.5 formula in plain Rust — re-derived here so we don't - // import the crate-private helper. Both the Transform no-CMM arm - // and the resolver fallback must agree with this. - fn spec_additive_clamp(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] { - let cf = c as f32 / 255.0; - let mf = m as f32 / 255.0; - let yf = y as f32 / 255.0; - let kf = k as f32 / 255.0; - let r = ((1.0 - (cf + kf).min(1.0)) * 255.0).round() as u8; - let g = ((1.0 - (mf + kf).min(1.0)) * 255.0).round() as u8; - let b = ((1.0 - (yf + kf).min(1.0)) * 255.0).round() as u8; - [r, g, b] + // The process-ink converter via the public API. Both the Transform + // no-CMM arm (which devolves to `images::cmyk_pixel_to_rgb`) and the + // resolver fallback route through the same `color::cmyk_to_rgb`, so + // both must agree with this byte-for-byte. + fn spec_process_ink(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] { + let (r, g, b) = pdf_oxide::color::cmyk_to_rgb( + c as f32 / 255.0, + m as f32 / 255.0, + y as f32 / 255.0, + k as f32 / 255.0, + ); + [ + (r * 255.0).round() as u8, + (g * 255.0).round() as u8, + (b * 255.0).round() as u8, + ] } for (c, m, y, k) in [ @@ -1365,10 +1392,10 @@ fn additive_clamp_consistency_between_extractors_helper_and_no_output_intent_arm (200, 100, 50, 25), ] { let from_transform = t.convert_cmyk_pixel(c, m, y, k); - let from_spec = spec_additive_clamp(c, m, y, k); + let from_spec = spec_process_ink(c, m, y, k); assert_eq!( from_transform, from_spec, - "Transform no-CMM fallback must agree with §10.3.5 spec on CMYK({c},{m},{y},{k}); \ + "Transform no-CMM fallback must agree with the process-ink converter on CMYK({c},{m},{y},{k}); \ transform={from_transform:?}, spec={from_spec:?}" ); } @@ -1652,37 +1679,24 @@ fn page_level_default_gray_routes_bare_device_gray_through_override() { let (r, g, b, a) = pixel_at(&rgba, 50, 50); // The Separation tint transform `{ 0.0 exch 0.0 0.0 }` consumes the // single gray-component input and emits CMYK(0, gray, 0, 0). For - // gray=0.5 the alternate is CMYK(0, 0.5, 0, 0) → §10.3.5 produces - // R=1, G=1-0.5=0.5, B=1. The 0.5 channel rounds to 128 (round-half- - // up via Rust f32 → u8 cast at the pixel-writer boundary). The - // R=255, B=255 channels and the M-only behaviour are the - // discriminating signal — they are byte-exact and would be - // ABSENT if the override was bypassed (literal gray would produce - // RGB(128, 128, 128) with no magenta channel). + // gray=0.5 the alternate is CMYK(0, 0.5, 0, 0), which the process-ink + // converter projects to a magenta-ish RGB (high R, mid B) - distinct + // from the literal gray RGB(128, 128, 128) a bypassed override would + // leave. R and B are the discriminating channels (G is 128 either + // way); pin the full tuple to the canonical converter. + let want = process_ink_rgb(0.0, 0.5, 0.0, 0.0); assert_eq!( - r, 255, - "/DefaultGray override → Separation magenta projection must produce R=255 \ - (additive-clamp of CMYK(0,0.5,0,0) leaves R=1.0); got ({r},{g},{b},{a}). \ - (128,*,*) means the override was bypassed and the literal gray landed." - ); - assert_eq!( - b, 255, - "/DefaultGray override → Separation magenta projection must produce B=255; \ - got ({r},{g},{b},{a})" + (r, g, b), + want, + "/DefaultGray override -> Separation magenta projection must produce the \ + process-ink RGB {want:?} (high R, mid B); got ({r},{g},{b},{a}). \ + RGB(128,128,128) would mean the override was bypassed and the literal \ + gray landed." ); - // tiny-skia's f32 → u8 conversion at color.rs:444 is - // `(c * 255.0 + 0.5) as u8` — round-half-up via truncation. For - // c=0.5 that's 0.5 * 255.0 + 0.5 = 128.0 → 128, deterministic - // across platforms and tiny-skia builds. Earlier rounds asserted - // a (120..=130) tolerance against a supposed platform-dependent - // rounding; the actual conversion is exact, so pin the byte. - assert_eq!( - g, 128, - "/DefaultGray override → Separation magenta projection must produce \ - G=128 (additive-clamp of CMYK(0,0.5,0,0) gives G=0.5; tiny-skia's \ - f32→u8 conversion is (c*255.0+0.5) as u8 = 128, deterministic); \ - got G={g}, full pixel ({r},{g},{b},{a}). G=255 would mean no \ - magenta — override bypassed." + assert!( + r > 200 && b > 160 && b < 230, + "discriminating channels must show the magenta projection (R high, B mid), \ + not gray 128; got ({r},{g},{b},{a})" ); assert_eq!(a, 255, "alpha=1 paint must be fully opaque; got a={a}"); } @@ -2312,7 +2326,7 @@ fn output_intent_perceptual_vs_absolute_colorimetric_produces_different_rgb() { /// ``` /// /// **Negative-pin commit `fda9b6f`:** -/// The negative pin (`*_without_output_intent_renders_additive_clamp`) +/// The negative pin (`*_without_output_intent_renders_process_inks`) /// is a regression guard, not a failing test. Verified by planting the /// commit's test on its parent `656c119`: it passed even there because /// the no-OutputIntent fallback was the shipped behaviour. The impl @@ -2328,7 +2342,7 @@ fn qa_tdd_discipline_verification_report() { // by referencing the two test functions whose behaviour the report // describes. let _ = device_cmyk_paint_with_output_intent_renders_via_icc_not_additive_clamp; - let _ = device_cmyk_paint_without_output_intent_renders_additive_clamp; + let _ = device_cmyk_paint_without_output_intent_renders_process_inks; } // =========================================================================== @@ -2539,7 +2553,7 @@ fn separation_type4_alt_devicecmyk_composite_routes_through_output_intent() { /// into a no-OutputIntent fixture (which would imply some hard-coded /// CMM hung around the renderer rather than the configured route). #[test] -fn separation_type4_alt_devicecmyk_without_output_intent_renders_additive_clamp() { +fn separation_type4_alt_devicecmyk_without_output_intent_renders_process_inks() { // Inline-build a PDF identical to // `build_pdf_separation_type4_devicecmyk_with_output_intent` but // without /OutputIntents. Object IDs shift down by one because the @@ -2599,9 +2613,9 @@ fn separation_type4_alt_devicecmyk_without_output_intent_renders_additive_clamp( let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (255u8, 0, 255, 255), + process_ink_rgba(0.0, 1.0, 0.0, 0.0), "Separation Type-4 /DeviceCMYK alternate without /OutputIntents must \ - fall through to §10.3.5 additive-clamp of CMYK(0,1,0,0) = (255,0,255); \ + fall through to the process-ink converter for CMYK(0,1,0,0) (magenta #EC008C); \ got ({r},{g},{b},{a})" ); } @@ -3538,9 +3552,9 @@ fn qa_round4_branding_green_mark_routes_through_output_intent() { let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (166u8, 230, 0, 255), - "without /OutputIntents the green-mark CMYK build must produce the §10.3.5 \ - additive-clamp value RGB(166, 230, 0) — vivid lime. This is the pre-#97 \ + process_ink_rgba(0.30, 0.05, 0.95, 0.05), + "without /OutputIntents the green-mark CMYK build must produce the \ + process-ink value for CMYK(0.30, 0.05, 0.95, 0.05) - the no-OI \ baseline; got ({r},{g},{b},{a})" ); } @@ -3659,7 +3673,7 @@ fn qa_round4_real_branding_fixture_honest_gap() { /// accept N=3 would route CMYK bytes through an RGB profile and either /// panic at qcms's channel-count assert or emit garbage RGB. #[test] -fn output_intent_n3_rgb_profile_rejected_at_reader_falls_through_to_additive_clamp() { +fn output_intent_n3_rgb_profile_rejected_at_reader_falls_through_to_process_inks() { // Build a PDF whose /OutputIntents entry declares /N 3 on its // /DestOutputProfile stream. The body bytes don't have to parse as a // real RGB ICC profile because the accessor filters on /N before it @@ -3711,8 +3725,8 @@ fn output_intent_n3_rgb_profile_rejected_at_reader_falls_through_to_additive_cla let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (191u8, 255, 255, 255), - "an N=3 OutputIntent must fall through to §10.3.5 additive-clamp \ + process_ink_rgba(0.25, 0.0, 0.0, 0.0), + "an N=3 OutputIntent must fall through to the process-ink converter \ byte-for-byte for /DeviceCMYK paint; got ({r},{g},{b},{a})" ); } @@ -3750,9 +3764,9 @@ fn output_intent_with_outputcondition_string_only_no_destoutputprofile_falls_thr let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (191u8, 255, 255, 255), - "a /OutputCondition-only entry must fall through to §10.3.5 \ - additive-clamp byte-for-byte; got ({r},{g},{b},{a})" + process_ink_rgba(0.25, 0.0, 0.0, 0.0), + "a /OutputCondition-only entry must fall through to the process-ink \ + converter byte-for-byte; got ({r},{g},{b},{a})" ); } @@ -3845,7 +3859,7 @@ fn output_intent_array_picks_first_cmyk_entry_skipping_rgb() { /// the entry is skipped, and the renderer falls through to §10.3.5. /// /// This is distinct from the header-only probe at -/// `output_intent_with_unparseable_profile_falls_through_to_additive_clamp`: +/// `output_intent_with_unparseable_profile_falls_through_to_process_inks`: /// that probe pins fall-through happens INSIDE `Transform::convert_cmyk_pixel` /// (the header parses, qcms refuses to build the CMM). This probe pins the /// earlier rejection where `IccProfile::parse` returns None up front — no @@ -3873,9 +3887,9 @@ fn output_intent_malformed_iccbased_stream_falls_through() { let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (191u8, 255, 255, 255), + process_ink_rgba(0.25, 0.0, 0.0, 0.0), "a malformed /DestOutputProfile stream (sub-header-length garbage) \ - must fall through to §10.3.5 additive-clamp byte-for-byte without \ + must fall through to the process-ink converter byte-for-byte without \ panicking; got ({r},{g},{b},{a})" ); } diff --git a/tests/test_render_resolution_pipeline_qa_wave1.rs b/tests/test_render_resolution_pipeline_qa_wave1.rs index 63fa3ff35..0764f6bce 100644 --- a/tests/test_render_resolution_pipeline_qa_wave1.rs +++ b/tests/test_render_resolution_pipeline_qa_wave1.rs @@ -647,12 +647,14 @@ fn qa_iccbased_cmyk_n4_fill_paints_centre_cyan() { let doc = PdfDocument::from_bytes(buf).expect("PDF parses"); let on = render_with_pipeline(&doc, true); // ICCBased N=4 reads /N=4 and treats components as CMYK. The fill - // here is (1,0,0,0) = pure cyan → §10.3.5 additive-clamp gives - // R=0, G=1, B=1 (cyan). Pin that the rect centre is cyan. + // here is (1,0,0,0) = pure cyan. With no usable embedded CMM the + // composite renderer takes the process-ink fallback, so the cyan + // corner is #00ADEF = (0, 173, 239) - blue-leaning cyan, NOT the + // additive (0, 255, 255). Pin that the rect centre is process cyan. let (r, g, b, _) = center_pixel(&on); assert!( - r < 30 && g > 220 && b > 220, - "ICCBased N=4 cyan fill must paint cyan at centre; got ({r},{g},{b})" + r < 30 && (150..=195).contains(&g) && b > 220, + "ICCBased N=4 cyan fill must paint process-ink cyan #00ADEF at centre; got ({r},{g},{b})" ); } @@ -694,10 +696,12 @@ fn qa_devicen_multi_colorant_type4_pipeline_resolves() { let doc = PdfDocument::from_bytes(bytes).expect("PDF parses"); let on = render_with_pipeline(&doc, true); + // CMYK(0,1,0,0) composites through the process-ink converter to the + // magenta corner #EC008C = (236, 0, 140), NOT the additive (255,0,255). let (r, g, b, _) = center_pixel(&on); assert!( - r > 200 && g < 60 && b > 200, - "pipeline DeviceN Type-4 must resolve to magenta, got ({r}, {g}, {b})" + r > 200 && g < 60 && (110..=170).contains(&b), + "pipeline DeviceN Type-4 must resolve to process-ink magenta #EC008C, got ({r}, {g}, {b})" ); } diff --git a/tests/test_render_resolution_pipeline_qa_wave2.rs b/tests/test_render_resolution_pipeline_qa_wave2.rs index 508bdd53b..41a57ef13 100644 --- a/tests/test_render_resolution_pipeline_qa_wave2.rs +++ b/tests/test_render_resolution_pipeline_qa_wave2.rs @@ -659,11 +659,14 @@ fn qa_text_three_consecutive_tj_type4_separation_capability() { let on = render_with_pipeline(&doc, true); // Pipeline: magenta. Anti-aliased halo around small glyphs blends - // magenta with white background — pure-magenta pixels are R=255,B=255 - // and halo lifts G toward 255 too. Channel SHAPE: R/B >= G + margin. + // magenta with white background. Pure process-ink magenta is + // #EC008C = (236, 0, 140); averaged with the white halo the ink + // trends (~249, ~183, ~222), so R-G ~ 66 and B-G ~ 39 (process + // magenta's blue is lower than the old additive B=255). Channel + // SHAPE: R/B >= G + margin, well clear of solid black's R=G=B. let avg_on = average_ink_rgb(&on, 0, 30, 100, 95).expect("pipeline: magenta ink"); assert!( - avg_on.0 > avg_on.1 + 40.0 && avg_on.2 > avg_on.1 + 40.0, + avg_on.0 > avg_on.1 + 25.0 && avg_on.2 > avg_on.1 + 25.0, "pipeline: three Tj glyphs under Type 4 Separation must paint magenta-shaped \ (R,B above G), got ({:.1}, {:.1}, {:.1})", avg_on.0, diff --git a/tests/test_render_resolution_pipeline_qa_wave3.rs b/tests/test_render_resolution_pipeline_qa_wave3.rs index 83eb9379e..aca65ae2e 100644 --- a/tests/test_render_resolution_pipeline_qa_wave3.rs +++ b/tests/test_render_resolution_pipeline_qa_wave3.rs @@ -511,7 +511,7 @@ fn build_pdf_standard_image_indexed( /// paints the CMYK pixel data unchanged. #[test] fn qa_standard_image_cmyk_pass_through_paints_magenta_centre() { - // 4x4 CMYK pixels, all (0, 1, 0, 0) → magenta under additive clamp. + // 4x4 CMYK pixels, all (0, 1, 0, 0) -> process-ink magenta #EC008C. // Each pixel is 4 bytes (one per component). let mut pixels = Vec::with_capacity(16 * 4); for _ in 0..16 { @@ -521,12 +521,12 @@ fn qa_standard_image_cmyk_pass_through_paints_magenta_centre() { let bytes = build_pdf_standard_image_named_cs(content, 4, 4, 8, &pixels, "DeviceCMYK"); let doc = PdfDocument::from_bytes(bytes).expect("PDF parses"); let on = render_with_pipeline(&doc, true); - // CMYK(0, 1, 0, 0) → §10.3.5 additive clamp → R=1, G=0, B=1 - // (magenta). Pin the centre pixel. + // CMYK(0, 1, 0, 0) -> process-ink magenta corner #EC008C = + // (236, 0, 140). Pin the centre pixel. let (r, g, b, _a) = center_pixel(&on); assert!( - r > 200 && g < 60 && b > 200, - "DeviceCMYK image (0,1,0,0) must render as magenta at centre, got ({r}, {g}, {b})" + r > 200 && g < 60 && (110..=170).contains(&b), + "DeviceCMYK image (0,1,0,0) must render as process-ink magenta at centre, got ({r}, {g}, {b})" ); } @@ -1046,8 +1046,8 @@ fn qa_form_xobject_with_inner_image_mask_type4_separation_capability_gain() { // Pipeline runs the Type 4 program → magenta. let (r_on, g_on, b_on, _a) = center_pixel(&on); assert!( - r_on >= 250 && g_on <= 5 && b_on >= 250, - "Form-nested Type 4 Separation ImageMask must paint magenta, got ({r_on}, {g_on}, {b_on})" + r_on >= 225 && g_on <= 5 && (125..=155).contains(&b_on), + "Form-nested Type 4 Separation ImageMask must paint process-ink magenta, got ({r_on}, {g_on}, {b_on})" ); } @@ -1536,8 +1536,8 @@ fn qa_image_mask_100_paints_type4_separation_capability_at_scale() { // (50, 50), 8x8, so centre ≈ (54, 54). let (r_on, g_on, b_on, _a) = pixel_at(&on, 54, 54); assert!( - r_on > 200 && g_on < 60 && b_on > 200, - "pipeline 100-paint: tile centre must be magenta, got ({r_on},{g_on},{b_on})" + r_on > 200 && g_on < 60 && (110..=170).contains(&b_on), + "pipeline 100-paint: tile centre must be process-ink magenta, got ({r_on},{g_on},{b_on})" ); } @@ -1568,8 +1568,8 @@ fn qa_image_mask_devicen_multi_colorant_type4_capability() { let on = render_with_pipeline(&doc, true); let (r_on, g_on, b_on, _a) = center_pixel(&on); assert!( - r_on > 200 && g_on < 60 && b_on > 200, - "pipeline DeviceN Type-4 ImageMask must paint magenta, got ({r_on},{g_on},{b_on})" + r_on > 200 && g_on < 60 && (110..=170).contains(&b_on), + "pipeline DeviceN Type-4 ImageMask must paint process-ink magenta, got ({r_on},{g_on},{b_on})" ); } diff --git a/tests/test_render_resolution_pipeline_qa_wave4.rs b/tests/test_render_resolution_pipeline_qa_wave4.rs index a9a245c33..c0ba651c9 100644 --- a/tests/test_render_resolution_pipeline_qa_wave4.rs +++ b/tests/test_render_resolution_pipeline_qa_wave4.rs @@ -731,12 +731,12 @@ fn qa_inline_separation_devicecmyk_type4_capability_pin() { ); let doc = PdfDocument::from_bytes(bytes).expect("PDF parses"); let on = render_with_pipeline(&doc, true); - // Pipeline evaluates the Type-4 program → CMYK(0, 1, 0, 0) → - // RGB(1, 0, 1) magenta. + // Pipeline evaluates the Type-4 program -> CMYK(0, 1, 0, 0) -> + // process-ink magenta #EC008C = (236, 0, 140). let (r_on, g_on, b_on, _) = pixel_at(&on, 5, 50); assert!( - r_on >= 250 && g_on <= 5 && b_on >= 250, - "pipeline path must paint Type-4 magenta at C0 end; got ({r_on}, {g_on}, {b_on})" + r_on >= 225 && g_on <= 5 && (125..=155).contains(&b_on), + "pipeline path must paint Type-4 process-ink magenta at C0 end; got ({r_on}, {g_on}, {b_on})" ); } @@ -767,11 +767,11 @@ fn qa_iccbased_n4_cmyk_endpoint_pipeline_corrects_inline_truncation() { let doc = PdfDocument::from_bytes(bytes).expect("PDF parses"); let on = render_with_pipeline(&doc, true); - // Pipeline path: ICC N=4 → CMYK → RGB(1, 0, 1) magenta. + // Pipeline path: ICC N=4 -> CMYK -> process-ink magenta #EC008C = (236, 0, 140). let (r_on, g_on, b_on, _) = center_pixel(&on); assert!( - r_on > 240 && g_on < 20 && b_on > 240, - "pipeline must convert ICCBased N=4 /C0 [0 1 0 0] through CMYK→RGB to magenta; \ + r_on > 225 && g_on < 20 && (125..=155).contains(&b_on), + "pipeline must convert ICCBased N=4 /C0 [0 1 0 0] through CMYK->RGB to process-ink magenta; \ got ({r_on}, {g_on}, {b_on})" ); } @@ -863,11 +863,11 @@ fn qa_devicen_two_colorant_type4_capability_pin() { let doc = PdfDocument::from_bytes(bytes).expect("PDF parses"); let on = render_with_pipeline(&doc, true); - // Pipeline: DeviceN/CMYK/Type-4 → magenta. + // Pipeline: DeviceN/CMYK/Type-4 -> process-ink magenta #EC008C = (236, 0, 140). let (r_on, g_on, b_on, _) = center_pixel(&on); assert!( - r_on > 240 && g_on < 20 && b_on > 240, - "pipeline DeviceN/CMYK/Type-4 must produce magenta; got ({r_on}, {g_on}, {b_on})" + r_on > 225 && g_on < 20 && (125..=155).contains(&b_on), + "pipeline DeviceN/CMYK/Type-4 must produce process-ink magenta; got ({r_on}, {g_on}, {b_on})" ); } diff --git a/tests/test_render_resolution_pipeline_qa_wave5.rs b/tests/test_render_resolution_pipeline_qa_wave5.rs index 59f1b144f..06d2ba504 100644 --- a/tests/test_render_resolution_pipeline_qa_wave5.rs +++ b/tests/test_render_resolution_pipeline_qa_wave5.rs @@ -322,7 +322,7 @@ fn qa_wave5_type4_separation_composite_renders_magenta() { let pixmap = render(&doc); let (r, g, b, _) = center_pixel(&pixmap); assert!( - r > 240 && g < 20 && b > 240, + r > 225 && g < 20 && (125..=155).contains(&b), "Type-4 Separation on composite path must render magenta; got ({r}, {g}, {b})" ); } @@ -351,9 +351,9 @@ fn qa_wave5_type4_separation_composite_renders_magenta() { /// /// Tracking name: WAVE5-DEFER-COLORRESOLVER-RGBA-ONLY-FOR-COMPOUND-SPACES. /// -/// The pin shape: the composite output is "cyan-coloured RGB -/// (0, 255, 255)" — bit-exact byte values from the additive-clamp -/// formula. A follow-up that wires the resolver to emit +/// The pin shape: the composite output is process-ink cyan +/// #00ADEF = (0, 173, 239) - the tetralinear cyan corner, NOT the old +/// additive-clamp (0, 255, 255). A follow-up that wires the resolver to emit /// `ResolvedColor::Cmyk` for DeviceCMYK sources would not change this /// composite output (the composite backend would still see the /// folded RGBA), so the pin survives the deferral closure. @@ -365,8 +365,8 @@ fn qa_wave5_defer_color_resolver_rgba_only_for_compound_spaces() { let pixmap = render(&doc); let (r, g, b, a) = center_pixel(&pixmap); assert!( - r < 5 && g > 250 && b > 250 && a == 255, - "DeviceCMYK(1,0,0,0) → additive-clamp RGB(0, 1, 1) → exact bytes (0, 255, 255, 255); \ + r < 5 && (160..=185).contains(&g) && b > 225 && a == 255, + "DeviceCMYK(1,0,0,0) -> process-ink cyan #00ADEF = (0, 173, 239); \ got ({r}, {g}, {b}, {a})" ); } @@ -471,7 +471,7 @@ fn qa_wave5_env_var_one_is_inert() { // magenta at the centre regardless. let (r, g, b, _) = center_pixel(&with_one); assert!( - r > 240 && g < 20 && b > 240, + r > 225 && g < 20 && (125..=155).contains(&b), "Type-4 Separation fill must paint magenta; got ({r}, {g}, {b})" ); } @@ -503,7 +503,7 @@ fn qa_wave5_env_var_zero_is_inert() { let (r, g, b, _) = center_pixel(&with_zero); assert!( - r > 240 && g < 20 && b > 240, + r > 225 && g < 20 && (125..=155).contains(&b), "Type-4 Separation fill must still paint magenta (toggle gone); got ({r}, {g}, {b})" ); } @@ -691,7 +691,7 @@ fn qa_wave5_pipeline_resolve_paint_gs_path_fill_type4_separation() { let pixmap = render(&doc); let (r, g, b, _) = center_pixel(&pixmap); assert!( - r > 240 && g < 20 && b > 240, + r > 225 && g < 20 && (125..=155).contains(&b), "Type 4 Separation path fill must produce magenta; got ({r}, {g}, {b})" ); } @@ -802,7 +802,7 @@ fn qa_wave5_pipeline_resolve_components_shading_type4_separation() { for x in [5u32, 25, 50, 75, 95] { let (r, g, b, _) = pixel_at(&pixmap, x, 50); assert!( - r > 240 && g < 30 && b > 240, + r > 225 && g < 30 && (125..=155).contains(&b), "shading endpoint (x={x}): Type-4 Separation must paint magenta; got ({r}, {g}, {b})" ); } @@ -859,23 +859,25 @@ fn qa_wave5_empty_content_stream_no_panic() { assert!(r > 250 && g > 250 && b > 250, "empty page must be white"); } -/// Probe 24 — DeviceCMYK full black via `0 0 0 1 k`. On an RGB composite -/// target the resolver folds CMYK → RGB via §10.3.5 additive-clamp. -/// CMYK(0,0,0,1) → RGB(0, 0, 0) = black. Pin. +/// Probe 24 - DeviceCMYK full black via `0 0 0 1 k`. On an RGB composite +/// target the resolver folds CMYK -> RGB via the process-ink converter. +/// CMYK(0,0,0,1) is the measured K-ink corner #231F20 = (35, 31, 32), +/// NOT pure black (0, 0, 0) - the exact defect #861 fixes. Pin. #[test] -fn qa_wave5_device_cmyk_full_black_renders_black_on_rgb_target() { +fn qa_wave5_device_cmyk_full_black_renders_k_ink_on_rgb_target() { let content = "0 0 0 1 k\n10 10 80 80 re\nf\n"; let bytes = build_pdf(content, ""); let doc = PdfDocument::from_bytes(bytes).expect("PDF parses"); let pixmap = render(&doc); let (r, g, b, _) = center_pixel(&pixmap); assert!( - r < 20 && g < 20 && b < 20, - "DeviceCMYK(0,0,0,1) must render as black on RGB; got ({r}, {g}, {b})" + (25..=50).contains(&r) && (20..=45).contains(&g) && (20..=45).contains(&b), + "DeviceCMYK(0,0,0,1) must render as the process-ink K-ink #231F20 on RGB, \ + not pure black; got ({r}, {g}, {b})" ); } -/// Probe 24b — DeviceCMYK pure cyan `1 0 0 0 k` → RGB(0, 1, 1). Pin. +/// Probe 24b - DeviceCMYK pure cyan `1 0 0 0 k` -> process-ink #00ADEF = (0, 173, 239). Pin. #[test] fn qa_wave5_device_cmyk_pure_cyan_renders_rgb_cyan_on_rgb_target() { let content = "1 0 0 0 k\n10 10 80 80 re\nf\n"; @@ -884,8 +886,8 @@ fn qa_wave5_device_cmyk_pure_cyan_renders_rgb_cyan_on_rgb_target() { let pixmap = render(&doc); let (r, g, b, _) = center_pixel(&pixmap); assert!( - r < 20 && g > 240 && b > 240, - "DeviceCMYK(1,0,0,0) must render as cyan on RGB; got ({r}, {g}, {b})" + r < 20 && (160..=185).contains(&g) && b > 225, + "DeviceCMYK(1,0,0,0) must render as process-ink cyan on RGB; got ({r}, {g}, {b})" ); } diff --git a/tests/test_transparency_flattening_adversarial.rs b/tests/test_transparency_flattening_adversarial.rs index a080b0f1b..0325cc6d3 100644 --- a/tests/test_transparency_flattening_adversarial.rs +++ b/tests/test_transparency_flattening_adversarial.rs @@ -610,24 +610,22 @@ fn fixture_rgb_pure_black_over_cmyk_backdrop() -> Vec { #[test] fn adversarial_rgb_pure_black_paint_does_not_panic() { - // Mechanism: backdrop CMYK(1, 1, 0, 0) renders to RGB(0, 0, 255) - // via the §10.3.5 inverse (C, M, Y, K=0) → R = 1−C = 0, - // G = 1−M = 0, B = 1−Y = 1. The pure-black RGB(0, 0, 0) paint at - // α=0.5 composites over that backdrop with tiny_skia's premul - // source-over. The B channel (the only non-zero one in either - // operand) lands at byte 127 — tiny_skia's f32→u8 quantisation on - // a half-coverage paint over a fully-opaque backdrop bottoms out - // at 127 rather than 128 due to round-half-to-even on 127.5. - // Probe pins the post-composite RGBA byte-exact AND confirms the - // render completes without panic. + // Mechanism: backdrop CMYK(1, 1, 0, 0) renders through the + // process-ink converter to the measured blue corner + // #2E3192 ~ RGB(46, 49, 146), NOT the additive (0, 0, 255). The + // pure-black RGB(0, 0, 0) paint at α=0.5 composites over that + // backdrop with tiny_skia's premul source-over: result = + // 0.5*(0,0,0) + 0.5*(46,49,146) = (23, 25, 73). Probe pins the + // post-composite RGBA byte-exact AND confirms the render completes + // without panic. let rgba = render_rgba(fixture_rgb_pure_black_over_cmyk_backdrop()); let (r, g, b, a) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b, a), - (0, 0, 127, 255), + (23, 25, 73, 255), "RGB pure-black at α=0.5 over a CMYK(1,1,0,0) backdrop must \ - render byte-exact (0, 0, 127, 255): the §10.3.5 inverse \ - drives the backdrop to RGB(0, 0, 255) and tiny_skia's premul \ + render byte-exact (23, 25, 73, 255): the process-ink converter \ + drives the backdrop to RGB(46, 49, 146) and tiny_skia's premul \ source-over blends the pure-black paint at α=0.5 to that. \ Got ({r}, {g}, {b}, {a})." ); @@ -658,27 +656,28 @@ fn fixture_rgb_paint_with_overprint_does_not_mirror() -> Vec { } #[test] -fn adversarial_rgb_overprint_paint_skips_mirror_no_panic() { +fn adversarial_rgb_overprint_paint_gamut_compresses_no_panic() { let rgba = render_rgba(fixture_rgb_paint_with_overprint_does_not_mirror()); let (r, g, b, a) = pixel_at(&rgba, 50, 50); - // Mechanism: overprint is honoured per-plate; on the composite - // RGBA pixmap path the overprint flags have no effect (the - // composite path does not implement /OP on RGB-source paints — - // see audit suite docstring §). The green RGB(0, 1, 0) paint at - // α=0.5 over the white backdrop therefore composites identically - // to the no-overprint case: tiny_skia premul source-over of - // (0, 127, 0, 127) over (255, 255, 255, 255) yields - // (127, 255, 127, 255). The /OP true flag's effect lives entirely - // on the sidecar's per-plate output; the composite RGB pixel is - // unaffected. This probe pins both the byte-exact composite AND - // the no-panic property the test was originally written around. - let (r_ref, g_ref, b_ref, a_ref) = (127, 255, 127, 255); + // Mechanism: under /OP true the RGB paint is mirrored to CMYK + // (`resolve_rgb_paint_to_cmyk` -> `color::rgb_to_cmyk`) and the + // composite path projects that CMYK back through the process-ink + // `color::cmyk_to_rgb`. The green RGB(0, 1, 0) is OUT of the process + // gamut, so the round-trip gamut-compresses it toward the process + // green corner rather than reproducing (0, 255, 0). At α=0.5 over + // white the composite lands at (127, 209, 143) - close to, but not, + // the additive (127, 255, 127). This gamut compression of an + // out-of-gamut sRGB paint under overprint is the physically-correct + // consequence of unifying the composite path on process inks (the + // forward and inverse move together within the gamut). This probe + // pins the byte-exact composite AND the no-panic property. + let (r_ref, g_ref, b_ref, a_ref) = (127, 209, 143, 255); assert_eq!( (r, g, b, a), (r_ref, g_ref, b_ref, a_ref), "RGB paint with /OP true under /ca 0.5: the composite RGB \ pixmap must be byte-exact ({r_ref}, {g_ref}, {b_ref}, {a_ref}) \ - — tiny_skia premul source-over of the green α=0.5 paint over \ - white. Got ({r}, {g}, {b}, {a})." + - the out-of-gamut green gamut-compresses through the process-ink \ + round-trip. Got ({r}, {g}, {b}, {a})." ); } diff --git a/tests/test_transparency_flattening_audit.rs b/tests/test_transparency_flattening_audit.rs index 4ac7b0712..64ed2a9b7 100644 --- a/tests/test_transparency_flattening_audit.rs +++ b/tests/test_transparency_flattening_audit.rs @@ -750,10 +750,10 @@ fn smask_bc_devicen_5_components_evaluates_tint_transform() { // visible) and from n=1/3/4 device-family cases. assert_eq!( (r, g, b), - (255, 64, 64), + (255, 56, 56), "ISO 32000-1 §11.6.5.2 + §8.6.6.5 /SMask /BC n=5 DeviceN: tint \ - transform must run and project to RGB via the alternate CMYK; \ - expected byte-exact (255, 64, 64); got ({r}, {g}, {b})" + transform must run and project to RGB via the alternate CMYK \ + (process-ink); expected byte-exact (255, 56, 56); got ({r}, {g}, {b})" ); } @@ -838,9 +838,9 @@ fn smask_bc_devicen_5_components_type0_sampled_byte_exact() { let (r, g, b, _) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b), - (255, 96, 96), - "§7.10.2 Type 0 sampled /BC tint-transform evaluation; expected \ - byte-exact (255, 96, 96); got ({r}, {g}, {b}). A regression to \ + (255, 85, 85), + "§7.10.2 Type 0 sampled /BC tint-transform evaluation (process-ink \ + projection); expected byte-exact (255, 85, 85); got ({r}, {g}, {b}). A regression to \ (255, 255, 255) means the Type 0 evaluator fell to None and the \ /BC pre-fill collapsed to the (0, 0, 0) black point — paint then \ shows through unmasked." @@ -911,9 +911,9 @@ fn smask_bc_devicen_5_components_type3_stitching_byte_exact() { let (r, g, b, _) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b), - (255, 170, 170), - "§7.10.4 Type 3 stitching /BC tint-transform evaluation; expected \ - byte-exact (255, 170, 170); got ({r}, {g}, {b}). A regression to \ + (255, 148, 148), + "§7.10.4 Type 3 stitching /BC tint-transform evaluation (process-ink \ + projection); expected byte-exact (255, 148, 148); got ({r}, {g}, {b}). A regression to \ (255, 255, 255) means the Type 3 evaluator fell to None and the \ /BC pre-fill collapsed to the (0, 0, 0) black point." ); @@ -1189,9 +1189,9 @@ fn smask_bc_devicen_5_components_iccbased_alternate_byte_exact() { let (r, g, b, _) = pixel_at(&rgba, 50, 50); assert_eq!( (r, g, b), - (255, 127, 127), - "§8.6.5.5 ICCBased /BC projection via /Alternate fallback; \ - expected byte-exact (255, 127, 127); got ({r}, {g}, {b})" + (255, 111, 111), + "§8.6.5.5 ICCBased /BC projection via /Alternate fallback (process-ink); \ + expected byte-exact (255, 111, 111); got ({r}, {g}, {b})" ); } @@ -2071,16 +2071,16 @@ fn overprint_composite_overlap_differs_from_no_overprint() { let (r_no, g_no, b_no, _) = pixel_at(&rgba_no, 50, 50); assert_eq!( (r_op, g_op, b_op), - (128, 255, 0), + (131, 178, 41), "§11.7.4.3 OPM=1: CMYK(0.5,0,0,0) + CMYK(0,0,1,0) under /op true \ - must compose to byte-exact RGB (128, 255, 0) at the overlap; \ + must compose to byte-exact process-ink RGB (131, 178, 41) at the overlap; \ got ({r_op}, {g_op}, {b_op})" ); assert_eq!( (r_no, g_no, b_no), - (255, 255, 0), - "§10.3.5 baseline: CMYK(0,0,1,0) opaque SourceOver over cyan must \ - yield byte-exact RGB (255, 255, 0) at the overlap; got \ + (255, 242, 0), + "process-ink baseline: CMYK(0,0,1,0) opaque SourceOver over cyan must \ + yield byte-exact RGB (255, 242, 0) at the overlap; got \ ({r_no}, {g_no}, {b_no})" ); assert_ne!( @@ -2145,35 +2145,28 @@ fn outputintent_then_transparency_composite_before_convert() { // PDF (15, 15) is firmly inside the lower-only region. // PDF y=15 → image y=85. let (r, g, b, _) = pixel_at(&rgba, 15, 85); - // CMYK(0.5, 0, 0, 0) via additive-clamp = RGB(128, 255, 255): - // R = (1 - C - K)·255 = (1 - 0.5 - 0)·255 = 127.5 → byte 128 - // G = (1 - M - K)·255 = (1 - 0 - 0)·255 = 255 - // B = (1 - Y - K)·255 = (1 - 0 - 0)·255 = 255 - // Byte-exact reference: the rasteriser produces (128, 255, 255) - // for every pixel in the lower-only region (no AA inside the - // rect interior). + // CMYK(0.5, 0, 0, 0) via the process-ink converter = 0.5*paper + + // 0.5*cyan corner = RGB(128, 214, 247). Byte-exact reference: the + // rasteriser produces (128, 214, 247) for every pixel in the + // lower-only region (no AA inside the rect interior). assert_eq!( (r, g, b), - (128, 255, 255), - "ISO 32000-1 §10.3.5 additive-clamp CMYK→RGB: lower-paint-only \ - region must show byte-exact (128, 255, 255); got ({r}, {g}, {b})" - ); - - // Sample inside the overlap region. Convert-first order: - // lower paint → RGB(128, 255, 255), opaque - // upper paint → RGB(255, 255, 128) per additive-clamp at /ca 0.5 - // tiny_skia source-over premul math at α=0.5: - // r: round((128·128 + (255 - 128)·255) / 255) = 192 - // g: 255 - // b: round((255·128 + (128)·(255-128)/255)) = 191 - // The R/B asymmetry comes from tiny_skia's u8 premul rounding; - // the byte-exact reference is (192, 255, 191). + (128, 214, 247), + "process-ink CMYK->RGB: lower-paint-only \ + region must show byte-exact (128, 214, 247); got ({r}, {g}, {b})" + ); + + // Sample inside the overlap region. Convert-first order, process-ink: + // lower paint -> RGB(128, 214, 247), opaque + // upper paint -> process-ink RGB(255, 249, 128) at /ca 0.5 + // tiny_skia source-over premul math at alpha=0.5 yields the + // byte-exact reference (192, 231, 187). let (r2, g2, b2, _) = pixel_at(&rgba, 50, 50); assert_eq!( (r2, g2, b2), - (192, 255, 191), + (192, 231, 187), "overlap must show byte-exact convert-first composite \ - (192, 255, 191); got ({r2}, {g2}, {b2})" + (192, 231, 187); got ({r2}, {g2}, {b2})" ); }