From ebbbf29f1ef0de36de0c86af525e5883748ad5e8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 06:19:22 -0800 Subject: [PATCH] Gradient: Perceptual spot ink mixes stay continuous from red to blue Mixed in display RGB, red and blue darken past blue's lightness and come back. The weight search for a Perceptual DeviceN shading then found a red-side weight for stops near the blue stop, and the inks jumped there. When the display RGB mix's lightness turns back between two stops, the inks are mixed at each stop's mix position instead. --- crates/color/src/gradient.rs | 21 +++++++++++++++++---- crates/color/src/gradient/tests.rs | 19 +++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 652db3340..794f92aee 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -86,6 +86,9 @@ const TINT_STEPS: u32 = 16; const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0; /// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for. const MAX_INK_TINTS: usize = 4096; +/// How many even steps [`Gradient::ink_mixes`] checks the lightness of two stops' display RGB mix +/// at, to tell whether it moves steadily from one stop's lightness to the other's. +const INK_MIX_STEPS: u32 = 64; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly /// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved @@ -401,8 +404,10 @@ impl Gradient { /// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab /// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix /// position itself when the two colors' lightness differs by less than - /// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than - /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + /// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere + /// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even + /// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more + /// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). pub fn ink_mixes(&self) -> Option> { use crate::libraries::srgb_to_oklab; if self.method.is_classic() || !self.fits_ink_tints() { @@ -414,8 +419,16 @@ impl Gradient { out.push((s.offset, i, 0.0, s.opacity)); let Some(n) = self.stops.get(i + 1) else { continue }; let (l0, l1) = (lightness(&s.color), lightness(&n.color)); + let mixed_lightness = |w: f32| lightness(&GradientMethod::Classic.mix(&s.color, &n.color, w)); + // The mix position itself when the lightness doesn't tell the mixes apart. + let by_position = (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN + || !(0..=INK_MIX_STEPS) + .map(|k| mixed_lightness(k as f32 / INK_MIX_STEPS as f32)) + .collect::>() + .windows(2) + .all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0); for v in perceptual_mixes(s, n) { - let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN { + let weight = if by_position { v } else { let want = lightness(&self.method.mix(&s.color, &n.color, v)); @@ -423,7 +436,7 @@ impl Gradient { for _ in 0..TINT_STEPS { let mid = (lo + hi) / 2.0; // The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one. - if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 { + if (mixed_lightness(mid) - want) * (l1 - l0) < 0.0 { lo = mid; } else { hi = mid; diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 1e75feb33..1005b8430 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -585,3 +585,22 @@ fn ink_mixes_follow_the_perceptual_curve() { g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect(); assert_eq!(g.ink_mixes(), None); } + +/// From red to blue the display RGB mix darkens past blue's lightness and comes back, so a +/// weight found by lightness near the blue stop would be a red-side one and the inks would jump +/// there. The weights are the mix positions instead, and reach the blue stop's inks steadily. +#[test] +fn ink_mixes_use_the_mix_position_when_the_lightness_turns_back() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red), GradientStop::new(1.0, blue)]); + g.method = GradientMethod::Perceptual; + let mixes = g.ink_mixes().expect("a Perceptual gradient's curve"); + assert_eq!(mixes[mixes.len() - 1], (1.0, 1, 0.0, 1.0), "the blue stop"); + let between: Vec<_> = mixes.iter().filter(|m| m.1 == 0 && m.0 > 0.0).collect(); + assert!(!between.is_empty(), "stops on the curve between red and blue: {mixes:?}"); + // With the midpoint at ½, a stop's offset on the curve is its mix position. + for (t, _, w, _) in &between { + assert!((w - t).abs() < 1e-6, "at {t}: weight {w}"); + } + assert!(mixes.windows(2).filter(|p| p[1].1 == 0).all(|p| p[0].2 <= p[1].2), "the weights rise: {mixes:?}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 02329b46d..fe801adcc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1387,7 +1387,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi `pdf::tests::dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill` and the Gradient panel's `the_method_menu_makes_the_gradient_perceptual`. Scores and hours unchanged. -PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html); [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html)). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients, Perceptual gradients that mix a spot color with RGB or Lab stops, and Perceptual gradients whose curve has more than 4,096 stops once expanded (VectorCraft's own limit) are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve` and `ink_mixes_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. +PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html); [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html)). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there, or at the stop's mix position between the two colors when that mix's lightness turns back on the way from one color to the other, as from red to blue (VectorCraft's own rule). Dithered fills, freeform gradients, Perceptual gradients that mix a spot color with RGB or Lab stops, and Perceptual gradients whose curve has more than 4,096 stops once expanded (VectorCraft's own limit) are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve`, `ink_mixes_follow_the_perceptual_curve` and `ink_mixes_use_the_mix_position_when_the_lightness_turns_back`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. In these Separation and DeviceN shadings, a white stop linked to a swatch that is not a spot color (a global White swatch, for example) is paper white, 0% of the ink, as an unlinked white stop is, so a gradient of one spot ink and such a stop stays a Separation shading; a stop linked to a white spot color stays a stop of that ink (VectorCraft's own rule). Test: `engine::tests::linked_stops::white_stops_linked_to_process_swatches_are_paper_in_spot_gradients`.