From 1ea4eefa806a024bda6dff5bc3b0b8a1b118783f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:03:16 -0800 Subject: [PATCH] Color: Generic CMYK separates nearby colors alike when an ink sits at 0 or 100% The Generic CMYK solver clamps each ink to 0..1 after a step. When an ink sits at 0 or 1 and the step pushes it further out, the clamp removes most of the step, and the solve can stop after a step under 1e-6. Where it stopped depended on the input's last float bits. For hex ed1e79, magenta ranged from 93.2% to 93.9% and yellow from 15.3% to 16.3% over 4,001 f32 values of its red, with 230 jumps over 0.001 between neighboring values. An ink at 0 or 1 whose descent direction points out of the range now stays where it is, and the step moves the other inks. The solve stops when every ink is held. Two new tests step a channel one f32 at a time and check that every ink stays within 0.001: the red of ed1e79 (cyan held at 0) and the blue of sRGB (0, 0, 0.25) (cyan and magenta held at 1). The solver still minimizes plain Lab distance to the gamut-mapped target. The CMYK values of many RGB colors change; hex 00ff00 now separates to C=61 M=0 Y=100 K=0 (cyan 0.6235 before, 0.6064 now), and the swatch test that pins its name is updated. --- crates/color/src/cms/generic.rs | 15 ++++++++++ crates/color/src/cms/tests.rs | 40 +++++++++++++++++++++++++++ crates/engine/src/tests/swatchcmds.rs | 2 +- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index cea213cd6..95abdf2f3 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -231,6 +231,21 @@ impl GenericCmyk { } g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::(); } + // An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks. + for p in 0..3 { + if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) { + a[p] = [0.0; 3]; + for row in &mut a { + row[p] = 0.0; + } + a[p][p] = 1.0; + g[p] = 0.0; + } + } + // Every ink is held at a bound, or the gradient is zero: no step can lower the error. + if g == [0.0; 3] { + break; + } let mut improved = false; for _ in 0..8 { let mut al = a; diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 99c16edf4..ecb4c1c59 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() { assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); } +/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few +/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024 +/// in all) stays within 0.001 on every ink. +#[test] +fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() { + let c = cms(); + let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]; + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -2000i32..=2000 { + let mut rgb = pink; + rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } + } + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!(lo[0], 0.0, "{lo:?}"); +} + +/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change +/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values +/// stays within 0.001 on every ink. +#[test] +fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() { + let c = cms(); + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -60i32..=60 { + let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } + } + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}"); +} + #[test] fn lab_colours_round_trip_through_the_cms() { let c = cms_no_bpc(); diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index 53876a218..2a74e194d 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -503,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { // A swatch made from a hex colour is CMYK as well; the document round-trips natively. let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string(); assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0)))); - assert_eq!(name, "C=62 M=0 Y=100 K=0"); + assert_eq!(name, "C=61 M=0 Y=100 K=0"); let json = serde_json::to_string(doc(&s)).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); }