diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 35573f4ff..bba8f6f33 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -31,6 +31,75 @@ impl GradientKind { } } +/// How a gradient mixes the colours between its stops (the Gradient panel's Method). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum GradientMethod { + /// Mixed in display RGB, as gradients always were. + #[default] + Classic, + /// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to + /// yellow passes through a light blue-grey rather than a dull mid grey. + Perceptual, +} + +impl GradientMethod { + /// Parse a method name (`classic`, `perceptual`; any case). + pub fn parse(s: &str) -> Option { + [Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + Self::Classic => "Classic", + Self::Perceptual => "Perceptual", + } + } + pub fn is_classic(&self) -> bool { + *self == Self::Classic + } + /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an + /// RGB colour between the two). + pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color { + match self { + Self::Classic => Color::lerp(a, b, t), + Self::Perceptual => { + use crate::libraries::{oklab_to_srgb, srgb_to_oklab}; + let (p, q, t) = (srgb_to_oklab(a.to_rgb()), srgb_to_oklab(b.to_rgb()), f64::from(t)); + let [r, g, bl] = oklab_to_srgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t); + let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; + Color::rgb(c(r), c(g), c(bl)) + } + } + } +} + +/// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring +/// expanded stops may stray from the perceptual mix: under half a colour level. +const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; +/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is +/// halved to keep within [`PERCEPTUAL_TOLERANCE`]. +const PERCEPTUAL_DEPTH: u32 = 10; + +/// 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 +/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order. +fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) { + let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb(); + let (c0, c1) = (at(v0), at(v1)); + let stray = [0.25, 0.5, 0.75] + .into_iter() + .flat_map(|u: f32| { + let c = at(v0 + (v1 - v0) * u); + (0..3).map(move |k| (c0[k] + (c1[k] - c0[k]) * u - c[k]).abs()) + }) + .fold(0.0, f32::max); + if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE { + let m = (v0 + v1) / 2.0; + perceptual_steps(a, b, (v0, m), depth + 1, out); + out.push(m); + perceptual_steps(a, b, (m, v1), depth + 1, out); + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientStop { /// 0..=1 along the gradient. @@ -79,6 +148,9 @@ pub struct Gradient { /// added, which hides the steps (banding) between the levels 8-bit colour has. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub dither: bool, + /// How the colours between the stops are mixed (Classic unless set; written only when not). + #[serde(default, skip_serializing_if = "GradientMethod::is_classic")] + pub method: GradientMethod, } impl Default for Gradient { @@ -91,11 +163,20 @@ impl Default for Gradient { impl Gradient { /// A gradient of `kind` through `stops`, with the default options. pub fn new(kind: GradientKind, stops: Vec) -> Self { - Self { kind, stops, dither: false } + Self { kind, stops, dither: false, method: GradientMethod::Classic } } - /// Colour and opacity at `t` (honours midpoints). + /// Colour and opacity at `t` (honours midpoints and the method). pub fn sample(&self, t: f32) -> (Color, f32) { - self.sample_with(t, Color::lerp) + self.sample_with(t, |a, b, v| self.method.mix(a, b, v)) + } + /// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it + /// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual + /// gradient's as [`Self::sample`] mixes them. + pub fn sample_keep_model(&self, t: f32) -> (Color, f32) { + match self.method { + GradientMethod::Classic => self.sample_with(t, Color::lerp_keep_model), + m => self.sample_with(t, |a, b, v| m.mix(a, b, v)), + } } /// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour /// model rather than display RGB). @@ -123,20 +204,47 @@ impl Gradient { None => (Color::BLACK, 1.0), } } - /// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints). + /// Stops expanded so that midpoints are represented as explicit stops (for renderers without + /// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that + /// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it). pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> { self.expanded().map(|(t, c, o, _)| (t, c, o)).collect() } /// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None` - /// on the gradient's own stops). + /// on the gradient's own stops and a Perceptual gradient's other in-between stops). pub fn expanded(&self) -> impl Iterator)> + '_ { self.stops.iter().enumerate().flat_map(move |(i, s)| { - let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { + let next = self.stops.get(i + 1); + let off_centre = (s.midpoint - 0.5).abs() > 1e-3; + // Classic: a stop at the midpoint, when it is off-centre. + let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| { let t = s.offset + (n.offset - s.offset) * s.midpoint; let (c, o) = self.sample(t); (t, c, o, Some(s.midpoint)) }); - std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) + // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the + // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them. + let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| { + let mut vs = Vec::new(); + perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs); + vs.push(0.5); + perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs); + let m = s.midpoint.clamp(0.01, 0.99); + vs.into_iter().map(move |v| { + let mid = v == 0.5; + let u = if mid { + s.midpoint + } else if v < 0.5 { + 2.0 * v * m + } else { + m + (v - 0.5) * 2.0 * (1.0 - m) + }; + let t = s.offset + (n.offset - s.offset) * u; + let o = s.opacity + (n.opacity - s.opacity) * v; + (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint)) + }) + }); + std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten()) }) } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each @@ -543,20 +651,31 @@ impl GradientPaint { } } /// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill - /// (`fill`) or a stroke: a freeform gradient, or a dithered fill (dither applies to fills - /// only)? + /// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a + /// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix + /// linearly between the stops, as SVG's and PDF's gradients do)? pub fn draws_as_pixels(&self, fill: bool) -> bool { - self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) + self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic() } /// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of /// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours - /// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered, each colour - /// channel of pixel (column, row) gets [`dither_noise`] before it is rounded to a level. - pub fn pixels(&self, bounds: Rect, area: Rect, cols: u16, rows: u16, rgb: &dyn Fn(&Color) -> [f32; 3]) -> impl Iterator + use<> { - self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], rgb) + /// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a + /// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets + /// [`dither_noise`] before it is rounded to a level. + pub fn pixels( + &self, + bounds: Rect, + area: Rect, + cols: u16, + rows: u16, + fill: bool, + rgb: &dyn Fn(&Color) -> [f32; 3], + ) -> impl Iterator + use<> { + self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], fill, rgb) } /// [`Self::pixels`] for the cells in columns `cells[0]` and rows `cells[1]` of the grid only, /// row by row: the same pixels, noise and all, the whole grid has there. + #[allow(clippy::too_many_arguments)] pub fn pixels_within( &self, bounds: Rect, @@ -564,9 +683,10 @@ impl GradientPaint { cols: u16, rows: u16, cells: [std::ops::Range; 2], + fill: bool, rgb: &dyn Fn(&Color) -> [f32; 3], ) -> impl Iterator + use<> { - let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), self.gradient.dither); + let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither); let ramp = Ramp::new(&self.gradient, rgb); let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1))); let (x0, y0) = (area.x0, area.y0); @@ -584,9 +704,10 @@ impl GradientPaint { } /// A gradient's colours ready to be sampled pixel by pixel, in a colour space of the caller's -/// (display RGB, or an ink's coverage): its stops, midpoints as stops (as -/// [`Gradient::expanded_stops`]), each turned into that space once, with colour and opacity -/// mixed linearly between them, each on its own (as the renderer's vector gradients mix them). +/// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's +/// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space +/// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's +/// vector gradients mix them). pub struct Ramp { stops: Vec<(f32, [f32; 4])>, } @@ -938,7 +1059,7 @@ mod tests { g.gradient.stops[0].midpoint = 0.3; g.gradient.stops[1].opacity = 0.5; let b = Rect::new(0.0, 0.0, 10.0, 2.0); - let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, &Color::to_rgb).collect(); + let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect(); assert_eq!(px.len(), 20); let ramp = Ramp::new(&g.gradient, &Color::to_rgb); for (i, p) in px.iter().take(10).enumerate() { @@ -952,4 +1073,140 @@ mod tests { assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]); assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]); } + + fn blue_yellow(method: GradientMethod) -> Gradient { + let mut g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], + ); + g.method = method; + g + } + + /// Illustrator 30's Perceptual method mixes colours "to match how the human eye perceives + /// differences between colors" + /// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html): + /// blue → yellow passes through a light blue-grey in OKLab, not the flat mid grey a display + /// RGB mix gives, and its lightness climbs evenly. + #[test] + fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() { + let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb(); + assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}"); + let p = blue_yellow(GradientMethod::Perceptual); + let [r, g, b] = p.sample(0.5).0.to_rgb(); + assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]); + assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]); + let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0]; + let steps: Vec = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect(); + assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}"); + // Ends and opacity as before. + assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff"); + assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00"); + } + + /// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual + /// curve (the midpoint diamond among them), more where it bends, close enough that the linear + /// ramp between them stays within half a colour level of the curve; a segment that hardly + /// bends needs few. + #[test] + fn perceptual_segments_expand_into_stops_on_the_curve() { + let pairs = [ + (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)), + (Color::BLACK, Color::rgb(1.0, 0.0, 0.0)), + (Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)), + (Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE), + ]; + for (a, b) in pairs { + let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); + p.method = GradientMethod::Perceptual; + p.stops[0].midpoint = 0.3; + p.stops[1].opacity = 0.5; + let ex: Vec<_> = p.expanded().collect(); + assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len()); + assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); + assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint"); + assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6)); + let ramp = Ramp::new(&p, &Color::to_rgb); + for k in 0..=2000 { + let t = k as f32 / 2000.0; + let (c, o) = p.sample(t); + let [r, g, b] = c.to_rgb(); + let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}"); + } + } + let mut flat = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))], + ); + flat.method = GradientMethod::Perceptual; + assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint"); + } + + /// Classic gradients expand as they always did: their stops, and a stop at each off-centre + /// midpoint, sampled in display RGB (so the renderer draws the same pixels as before). + #[test] + fn classic_gradients_expand_as_before() { + let mut g = Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) }, + GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) }, + GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) }, + GradientStop::new(1.0, Color::gray(0.3)), + ], + ); + // The expansion gradients had before they had a method. + let before: Vec<_> = g + .stops + .iter() + .enumerate() + .flat_map(|(i, s)| { + let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { + let t = s.offset + (n.offset - s.offset) * s.midpoint; + let (c, o) = g.sample_with(t, Color::lerp); + (t, c, o, Some(s.midpoint)) + }); + std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) + }) + .collect(); + assert_eq!(g.expanded().collect::>(), before); + assert_eq!(before.len(), 6); + assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp)); + assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model)); + g.method = GradientMethod::Perceptual; + assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix"); + } + + /// The method is written only when Perceptual, so files from before it (and Classic + /// gradients) read as Classic and new Classic files stay as they were. + #[test] + fn method_is_saved_only_when_perceptual() { + let classic = serde_json::to_value(Gradient::default()).unwrap(); + assert!(classic.get("method").is_none(), "{classic}"); + let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); + assert_eq!(old.method, GradientMethod::Classic); + let p = blue_yellow(GradientMethod::Perceptual); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["method"], "Perceptual"); + assert_eq!(serde_json::from_value::(v).unwrap(), p); + assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual)); + assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic)); + assert_eq!(GradientMethod::parse("smooth"), None); + } + + /// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there, + /// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke, + /// dithered or not. + #[test] + fn perceptual_gradients_draw_as_pixels() { + for fill in [true, false] { + assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill)); + assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill)); + let mut radial = blue_yellow(GradientMethod::Perceptual); + radial.kind = GradientKind::Radial; + radial.dither = true; + assert!(GradientPaint::new(radial).draws_as_pixels(fill)); + } + } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index d7921a948..9af14253d 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -16,7 +16,7 @@ pub mod swatch; pub use blend::BlendMode; pub use freeform::{Freeform, FreeformMode, FreeformPoint}; -pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; +pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop}; pub use libraries::SwatchLibrary; pub use swatch::{Swatch, SwatchGroup, default_swatches}; diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index a28bc808d..e96760d5c 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -98,7 +98,7 @@ pub fn builtin_library(id: &str) -> Option> { // ---------- colour construction ---------- /// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped. -fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { +pub(crate) fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b; let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b; let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b; @@ -113,7 +113,12 @@ fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { /// OKLab `[l, a, b]` of a colour's display RGB. pub fn to_oklab(c: &Color) -> [f64; 3] { - let [r, g, b] = c.to_rgb_uncalibrated().map(|v| { + srgb_to_oklab(c.to_rgb_uncalibrated()) +} + +/// OKLab `[l, a, b]` of sRGB `rgb` (0..=1, gamma encoded). +pub(crate) fn srgb_to_oklab(rgb: [f32; 3]) -> [f64; 3] { + let [r, g, b] = rgb.map(|v| { let v = f64::from(v); if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 8ba6fb174..7bf88ac16 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -89,7 +89,7 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient { out.stops = offsets .iter() .map(|&o| { - let (color, opacity) = g.sample_with(o, lerp_color); + let (color, opacity) = g.sample_keep_model(o); let (swatch, tint) = link_at(g, o); GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) } }) diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 62d1d536f..d274bdc2a 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -15,7 +15,7 @@ use kurbo::{BezPath, Point, Shape, Vec2}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; -use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color}; +use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP}; use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::FillRule; @@ -375,7 +375,7 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines: /// stroke's outline), coloured as [`gradient_slices`] paint it. pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_with(t.clamp(0.0, 1.0) as f32, lerp_color); + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. let marks: Vec = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; let max_dt = match (across, st.profile.is_some()) { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 6718566ac..ee9b582dd 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -12,7 +12,7 @@ use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::spread_scale; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; -use vectorcraft_doc::live::{GradientMesh, lerp_color}; +use vectorcraft_doc::live::GradientMesh; use vectorcraft_doc::pattern::{PatternDef, place_art}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; @@ -736,7 +736,7 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model }); } let geom = g.resolve(bounds); - Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color)) + Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32)) } /// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every @@ -745,7 +745,7 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model /// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the /// canvas draws it. fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> { - let at = |t: f64| (t, g.sample_with(t as f32, lerp_color)); + let at = |t: f64| (t, g.sample_keep_model(t as f32)); let mut v = vec![at(t0)]; for (i, s) in g.stops.iter().enumerate() { let o = s.offset as f64; @@ -844,7 +844,7 @@ impl Frame { /// is just its band, so nothing doubles up. fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { let step = (self.t1 - self.t0) / n as f64; - let color = |k: usize| g.sample_with((self.t0 + (k as f64 + 0.5) * step) as f32, lerp_color); + let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); if self.radial { let ellipse = |t: f64| { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index b155c49ea..a21a15a0a 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1237,7 +1237,7 @@ impl Exporter<'_> { } self.warn_raster(&fl.effects, || "fills".into()); self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { - if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) { + if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds, true) { return; } let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return }; @@ -1291,10 +1291,10 @@ impl Exporter<'_> { } /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, - /// blend mode) when it is an [area paint](Self::area_paint) of a fill (strokes check theirs - /// first): a pattern's tile instances - /// covering it, or an image of a gradient drawn as pixels placed on `bounds` (the painted - /// object's box, as on the canvas), clipped to it. False (nothing drawn) for other paints. + /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a + /// pattern's tile instances covering it, or an image of a gradient drawn as pixels placed on + /// `bounds` (the painted object's box, as on the canvas), clipped to it. False (nothing drawn) + /// for other paints. #[allow(clippy::too_many_arguments)] fn area( &mut self, @@ -1305,8 +1305,9 @@ impl Exporter<'_> { transparency: (f32, BlendMode), bounds: Rect, area: Rect, + fill: bool, ) -> bool { - if !self.area_paint(p, true) { + if !self.area_paint(p, fill) { return false; } let (opacity, mode) = transparency; @@ -1328,7 +1329,7 @@ impl Exporter<'_> { self.node(s, &inst, area, true); } } - Paint::Gradient(g) => self.field_image(s, g, bounds, area), + Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill), _ => {} } for _ in 0..pushes { @@ -1340,8 +1341,9 @@ impl Exporter<'_> { /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most - /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side). - fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) { + /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side), + /// with dither's noise when it paints a fill (`fill`). + fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { use vectorcraft_color::freeform::{painted_box, spread_scale}; let Some(b) = painted_box(bounds) else { return }; let area = area.abs(); @@ -1362,7 +1364,7 @@ impl Exporter<'_> { field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() } else { self.warn("linear and radial gradients drawn as pixels are exported as images"); - g.pixels(b, area, cols, rows, &|c| c.to_rgb()).flatten().collect() + g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect() }; let pixels = if self.convert { self.out.pixels() } else { None }; let (Some(img), Some(size)) = ( @@ -1383,13 +1385,14 @@ impl Exporter<'_> { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { return; } - if self.area_paint(&st.paint, false) { + if self.area_paint(&st.paint, false) && st.path_gradient().is_none() { // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as - // Outline Stroke makes it, painted with the pattern's tiles or the freeform's image. + // Outline Stroke makes it, painted with the pattern's tiles or the gradient's image (a + // gradient along or across the stroke is written as slices below). let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); if let Some(clip) = to_path(s, ®ion) { let area = region.bounding_box(); - self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area); + self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area, false); } return; } @@ -1588,7 +1591,7 @@ impl Exporter<'_> { }; let opaque = (1.0, BlendMode::Normal); if let Some(path) = to_path(s, &outlines) - && !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box()) + && !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box(), true) && let Some(paint) = ex.paint(s, fill, layout.bounds) { s.set_stroke(None); @@ -1658,7 +1661,7 @@ impl Exporter<'_> { for item in items { match item { AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { - if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box()) + if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box(), true) && let Some(paint) = ex.paint(s, &fl.paint, tb) { s.set_stroke(None); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 55990cafa..5d50c4038 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,8 +5,8 @@ //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and -//! freeform gradients and dithered gradient fills as images at the raster effects resolution -//! (clipped to what they paint), embedded images (resampled and +//! freeform and Perceptual gradients and dithered gradient fills as images at the raster +//! effects resolution (clipped to what they paint), embedded images (resampled and //! compressed as the Compression settings say) and text as outlined glyph paths, or as real //! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and //! given an output intent as the Output settings say. Hidden objects, diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 47e8da243..063e75ef3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1008,3 +1008,63 @@ fn dithered_gradient_fills_export_as_images_and_strokes_as_shadings() { let pdf = uncompressed(&plain).replace(' ', ""); assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType2")); } + +/// Illustrator's documentation: perceptual gradients aren't supported in PDF exports, and are +/// rasterised when saved that way +/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). +/// A Perceptual fill and stroke are drawn as images at the raster effects resolution, clipped to +/// what they paint; a Classic gradient stays a shading, and so does a gradient along a stroke +/// (slices through stops on the perceptual curve, as the canvas draws it). +#[test] +fn perceptual_gradients_export_as_images_and_classic_ones_as_shadings() { + let grad = |method| Paint::Gradient(Box::new(GradientPaint::new(Gradient { method, ..Default::default() }))); + let p = grad(vectorcraft_color::GradientMethod::Perceptual); + let shape = shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)); + let mut d = doc(200.0, 100.0); + d.raster_effects_ppi = 144.0; + add(&mut d, Node::path(NodeId(0), shape.clone(), Appearance::basic(p.clone(), p.clone(), 4.0))); + let pdf = uncompressed(&d).replace(' ', ""); + assert_eq!(pdf.matches("/Subtype/Image").count(), 2, "the fill and the stroke are images"); + assert!(pdf.contains("/Width200") && pdf.contains("/Height100"), "two pixels a point at 144 ppi"); + assert!(!pdf.contains("/ShadingType2"), "no shading"); + let mut along = Appearance::basic(Paint::None, p, 4.0); + if let Some(AppearanceItem::Stroke(st)) = along.items.get_mut(1) { + st.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; + } + let mut a = doc(200.0, 100.0); + add(&mut a, Node::path(NodeId(0), shape.clone(), along)); + let pdf = uncompressed(&a).replace(' ', ""); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType2"), "along the stroke: slices"); + let c = grad(vectorcraft_color::GradientMethod::Classic); + let mut plain = doc(200.0, 100.0); + add(&mut plain, Node::path(NodeId(0), shape, Appearance::basic(c.clone(), c, 4.0))); + let pdf = uncompressed(&plain).replace(' ', ""); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType2")); +} + +/// A dithered Perceptual gradient: the fill and the stroke are images (Perceptual), and dither +/// adds its noise to the fill's pixels only. +#[test] +fn dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill() { + let grad = |dither| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient { + dither, + method: vectorcraft_color::GradientMethod::Perceptual, + ..Default::default() + }))) + }; + let shape = shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)); + let export_with = |dither, stroke: bool| { + let mut d = doc(200.0, 100.0); + let (fill, stroke) = if stroke { (Paint::None, grad(dither)) } else { (grad(dither), Paint::None) }; + add(&mut d, Node::path(NodeId(0), shape.clone(), Appearance::basic(fill, stroke, 4.0))); + uncompressed(&d).replace(' ', "") + }; + for stroke in [false, true] { + let pdf = export_with(true, stroke); + assert_eq!(pdf.matches("/Subtype/Image").count(), 1, "stroke {stroke}: an image"); + assert!(!pdf.contains("/ShadingType"), "stroke {stroke}: no shading"); + } + assert_ne!(export_with(true, false), export_with(false, false), "the fill's pixels carry the noise"); + assert_eq!(export_with(true, true), export_with(false, true), "a stroke has no dither"); +} diff --git a/crates/render/src/dither.rs b/crates/render/src/dither.rs index 49a38d7ce..466eb93e4 100644 --- a/crates/render/src/dither.rs +++ b/crates/render/src/dither.rs @@ -57,7 +57,7 @@ fn cells(px: f64) -> u16 { fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: &[Range; 2], ink: Ink) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); let gr = &g.gradient; - (gr.kind as u8, gr.dither).hash(&mut h); + (gr.kind as u8, gr.dither, gr.method as u8).hash(&mut h); for s in &gr.stops { (s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h); hash_color(&s.color, &mut h); @@ -95,7 +95,7 @@ fn window(xf: Affine, w: u16, h: u16, b: Rect, cols: u16, rows: u16) -> Option<[ pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: [Range; 2], ink: Ink) -> Pixmap { let (w, h) = (win[0].len() as u16, win[1].len() as u16); let data = g - .pixels_within(b, b, cols, rows, win, &|c| ink.rgb(c)) + .pixels_within(b, b, cols, rows, win, true, &|c| ink.rgb(c)) .map(|[r, gr, bl, a]| { let pm = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8; vello_cpu::color::PremulRgba8 { r: pm(r), g: pm(gr), b: pm(bl), a } diff --git a/crates/render/src/tests_dither.rs b/crates/render/src/tests_dither.rs index 8d6938039..55e70e88e 100644 --- a/crates/render/src/tests_dither.rs +++ b/crates/render/src/tests_dither.rs @@ -133,3 +133,40 @@ fn a_dithered_gradient_without_stops_draws_nothing_and_samples_nothing() { let r = r.unwrap(); assert!((0..H).all(|y| (0..W).all(|x| r.pixel(x, y) == [255, 255, 255, 255])), "nothing is drawn"); } + +/// Blue to yellow across the document, mixed `method`'s way, dithered or not. +fn blue_yellow(method: vectorcraft_color::GradientMethod, dither: bool) -> Paint { + let mut g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], + ); + (g.method, g.dither) = (method, dither); + Paint::Gradient(Box::new(GradientPaint::new(g))) +} + +#[test] +fn a_dithered_perceptual_gradient_is_the_perceptual_mix_with_noise() { + use vectorcraft_color::GradientMethod::{Classic, Perceptual}; + // Classic first: the two share stops and box, and must not share a dithered grid. + let classic = render(&doc(blue_yellow(Classic, true), Paint::None), Affine::IDENTITY); + let dithered = render(&doc(blue_yellow(Perceptual, true), Paint::None), Affine::IDENTITY); + // Each pixel within a level or so of the perceptual mix across it (its grid cell's centre lies + // in it; the noise, rounding, and the mix's stops within half a level), and dither's noise + // moving many off the rounded mix at its centre. + let Paint::Gradient(want) = blue_yellow(Perceptual, false) else { panic!("a gradient") }; + let mix = |x: f32| want.gradient.sample(x / W as f32).0.to_rgb().map(|v| v.clamp(0.0, 1.0) * 255.0); + let mut noisy = 0; + for x in 5..W - 5 { + let p = dithered.pixel(x, 20); + let (a, m, b) = (mix(x as f32), mix(x as f32 + 0.5), mix(x as f32 + 1.0)); + let near = |k: usize| (a[k].min(b[k]) - 2.0..=a[k].max(b[k]) + 2.0).contains(&f32::from(p[k])); + assert!((0..3).all(near), "({x}, 20): {p:?} vs {a:?} to {b:?}"); + noisy += usize::from((0..3).any(|k| p[k] != m[k].round() as u8)); + } + assert!(noisy > 100, "dither's noise: {noisy} pixels off the rounded mix"); + // The middle: a blue-grey (OKLab), where Classic+Dither gives the display RGB mid grey. + let [r, g, b, _] = dithered.pixel(W / 2, 20).map(i32::from); + assert!(b > r + 20 && b > g + 10, "perceptual: {:?}", [r, g, b]); + let [r, g, b, _] = classic.pixel(W / 2, 20).map(i32::from); + assert!((r - b).abs() <= 4 && (g - b).abs() <= 4, "classic: {:?}", [r, g, b]); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 7b6b4e359..10f83f329 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1084,7 +1084,7 @@ impl Writer<'_> { if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) { p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3)))); p.extend(blend); - self.raster_paint(g, b, &[d.to_string()], rule, &p, ""); + self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true); } else { self.fill_props(f, rule, bounds, &mut p); p.extend(blend); @@ -1121,13 +1121,13 @@ impl Writer<'_> { let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf); p.extend(opacity); p.extend(blend); - self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side); + self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false); } (WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => { let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); p.extend(opacity); p.extend(blend); - self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side); + self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false); } (WrittenShape::Stroke { width }, _) => { p.push(("fill", "none".into())); @@ -1535,13 +1535,15 @@ impl Writer<'_> { } /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is - /// drawn as pixels ([`GradientPaint::draws_as_pixels`]): an `` over its painted box, - /// clipped to `clip` (outlines in user space filled by `rule`), in a group with `props` and - /// the `side` attribute. A freeform gradient's colour field, which SVG can't express, is - /// sampled as the canvas samples it (at twice the box's size in points); a linear or radial - /// gradient at the document's raster effects resolution (at most [`MAX_GRADIENT_PX`] a - /// side). Nothing is written for an empty box. - fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str) { + /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a + /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled + /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour + /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size + /// in points); a linear or radial gradient at the document's raster effects resolution (at most + /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty + /// box. + #[allow(clippy::too_many_arguments)] + fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; let Some(b) = painted_box(bounds) else { return }; let freeform = g.gradient.kind == GradientKind::Freeform; @@ -1554,7 +1556,7 @@ impl Writer<'_> { let k = self.doc.raster_effects_ppi / 72.0; let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; let (cols, rows) = (along(b.width()), along(b.height())); - (g.pixels(b, b, cols, rows, &Color::to_rgb).flatten().collect(), cols, rows) + (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) }; let mut png = Vec::new(); let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 5284726cc..59de887d2 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -37,6 +37,12 @@ //! describes dither as the gradient converted to pixels with subtle noise //! (). //! On characters the gradient stays a vector gradient, without dither (with a warning). +//! * **Perceptual gradients** fill or stroke objects as an `` of the gradient, at the +//! document's raster effects resolution, clipped to the shape or the stroke's outline: SVG +//! gradients mix linearly, and Illustrator's documentation says perceptual gradients are +//! rasterised in SVG +//! (). +//! On characters they are written as linear gradients through stops on the perceptual curve. //! * **Symbol instances** the shared `` would paint differently from the canvas (stained, //! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding //! pattern paints or unlinked masks, which stay on the page) are written as their own art. diff --git a/crates/svg/tests/raster_paints.rs b/crates/svg/tests/raster_paints.rs index a65b73c87..4e5b003d7 100644 --- a/crates/svg/tests/raster_paints.rs +++ b/crates/svg/tests/raster_paints.rs @@ -143,3 +143,90 @@ fn a_dithered_fill_is_an_image_at_the_raster_effects_resolution() { let svg = export_full(&plain, &ExportOptions::default(), None).svg; assert!(!svg.contains(" Paint { + let stops = vec![ + vectorcraft_color::GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), + vectorcraft_color::GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0)), + ]; + let mut g = Gradient::new(GradientKind::Linear, stops); + g.method = method; + Paint::Gradient(Box::new(GradientPaint::new(g))) +} + +/// Illustrator's documentation: perceptual gradients aren't supported in SVG exports, and are +/// rasterised when saved that way +/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). +/// A Perceptual fill and stroke are written as images of the gradient (at the raster effects +/// resolution, clipped to the shape and the stroke's outline) mixed as the canvas mixes them; a +/// Classic gradient stays a vector gradient. +#[test] +fn perceptual_gradients_are_images_and_classic_ones_stay_vector() { + use vectorcraft_color::GradientMethod; + let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0)); + let p = blue_yellow(GradientMethod::Perceptual); + let mut d = doc_with(Node::path(NodeId(0), shape.clone(), Appearance::basic(p.clone(), p, 4.0))); + d.raster_effects_ppi = 144.0; + let o = export_full(&d, &ExportOptions::default(), None); + let svg = &o.svg; + assert_eq!(svg.matches(" r + 20 && b > g + 10, "a blue-grey: {:?}", [r, g, b]); + let Paint::Gradient(want) = blue_yellow(GradientMethod::Perceptual) else { panic!("a gradient") }; + let [wr, wg, wb] = want.gradient.sample(160.5 / 320.0).0.to_rgb().map(|v| (v * 255.0).round() as i32); + assert!((r - wr).abs() <= 1 && (g - wg).abs() <= 1 && (b - wb).abs() <= 1, "{:?} vs {:?}", [r, g, b], [wr, wg, wb]); + // Read back, it looks as the canvas paints it. + let back = import(svg).unwrap(); + assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01); + // Classic: a vector gradient, no image. + let c = blue_yellow(GradientMethod::Classic); + let plain = doc_with(Node::path(NodeId(0), shape, Appearance::basic(c.clone(), c, 4.0))); + let svg = export_full(&plain, &ExportOptions::default(), None).svg; + assert!(!svg.contains(" 100, "dither's noise: {noisy} of 320 pixels differ"); + assert_eq!(png(true, true), png(false, true), "a stroke has no dither"); +}