From c5798f446eea5c721f7bddd7c6807384a52cb7dc Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 10:23:50 -0400 Subject: [PATCH] CMYK profiles carry every tag an ICC output profile requires (#2707) The built-in CMYK profiles embedded in CMYK exports had only the perceptual and colorimetric tables. ICC.1 requires an output profile to also have the saturation tables (A2B2, B2A2) and the gamut tag (gamt). Windows colour management rejects the profile without them ("Bitmap color context is not valid"), so Microsoft Photos couldn't show a CMYK TIFF that embedded it, while the RGB export opened fine. Write the saturation tables (same as colorimetric) and a 9x9x9 gamut table (in gamut where a Lab colour separates and returns within dE 3). Co-Authored-By: Claude Opus 5.5 --- crates/color/src/cms/icc.rs | 21 +++++++++++++++++++-- crates/color/src/cms/tests_icc.rs | 17 +++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index df105751f..5d4f1c974 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile { /// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table. const A2B_GRID: usize = 9; const B2A_GRID: usize = 17; +/// Grid points per Lab channel of the gamut table. +const GAMUT_GRID: usize = 9; /// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a /// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding). +/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents +/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK +/// TIFF that embedded one, #2707). pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile { let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32; let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16; @@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID)); b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q)); } + // Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut. + let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3)); + for i in 0..GAMUT_GRID.pow(3) { + let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID)); + let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0); + gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX }); + } let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n]; let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec| { let mut grid_points = [0u8; 16]; @@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr num_output_channels: outputs as u8, grid_points, clut: Some(LutStore::Store16(clut)), - // A curves sit on the device side, B curves on the PCS side. - a_curves: identity(4), + // A curves sit on the device side (the gamut tag's is its one output), B curves on the + // PCS side. + a_curves: identity(inputs + outputs - 3), b_curves: identity(3), m_curves: vec![], matrix: Matrix3d::IDENTITY, @@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr }; let a2b = table(4, 3, A2B_GRID, a2b); let b2a = table(3, 4, B2A_GRID, b2a); + let gamut = table(3, 1, GAMUT_GRID, gamut); let mut p = ColorProfile::default(); p.profile_class = ProfileClass::OutputDevice; p.color_space = DataColorSpace::Cmyk; @@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr p.white_point = moxcms::WHITE_POINT_D50.to_xyzd(); p.media_white_point = Some(p.white_point); p.lut_a_to_b_perceptual = Some(a2b.clone()); + p.lut_a_to_b_saturation = Some(a2b.clone()); p.lut_a_to_b_colorimetric = Some(a2b); p.lut_b_to_a_perceptual = Some(b2a.clone()); + p.lut_b_to_a_saturation = Some(b2a.clone()); p.lut_b_to_a_colorimetric = Some(b2a); + p.gamut = Some(gamut); labelled(p, name) } diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 4eca85781..b3796d444 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() { } } } + +/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the +/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos +/// then can't show a CMYK TIFF that embeds it. +#[test] +fn cmyk_profiles_have_the_tags_of_an_output_profile() { + for name in BUILTIN_CMYK { + let icc = icc_bytes(name).unwrap(); + let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize; + let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect(); + for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] { + assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want)); + } + let p = parse(&icc); + assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}"); + } +}