Gradients: a Perceptual gradient mixes its colours in OKLab, and SVG and PDF write it as an image

Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.

- Gradient gains `method` (Classic | Perceptual; serde default Classic,
  written only when Perceptual, so older files open as Classic and Classic
  files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
  sample_keep_model for the stroke meshes, Expand and blends); opacity stays
  linear. Renderers that mix linearly get stops on the perceptual curve,
  placed by halving each half segment where a straight line strays more than
  0.4 of a colour level (a nearly straight segment needs none). Classic
  gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
  Perceptual fill or stroke as an image at the document's raster effects
  resolution, clipped to what it paints. A gradient along or across a stroke
  stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
  noise (the canvas's dithered grids are keyed by method too); SVG and PDF
  write it as an image with the noise, and a dithered Perceptual stroke as an
  image without it, as the canvas draws it (GradientPaint::pixels, SVG's
  raster_paint and PDF's area are told whether they paint a fill).
This commit is contained in:
Taha-Mahmoodi
2026-10-23 01:53:29 +07:00
committed by bflatastic
parent e195fc94e2
commit 2bad646821
14 changed files with 516 additions and 59 deletions
+276 -19
View File
@@ -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> {
[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<f32>) {
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<GradientStop>) -> 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<Item = (f32, Color, f32, Option<f32>)> + '_ {
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<Item = [u8; 4]> + 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<Item = [u8; 4]> + 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<u16>; 2],
fill: bool,
rgb: &dyn Fn(&Color) -> [f32; 3],
) -> impl Iterator<Item = [u8; 4]> + 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<f64> = (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::<Vec<_>>(), 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::<Gradient>(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));
}
}
}
+1 -1
View File
@@ -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};
+7 -2
View File
@@ -98,7 +98,7 @@ pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
// ---------- 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) }
});
+1 -1
View File
@@ -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) }
})
+2 -2
View File
@@ -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<GradientMesh> {
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<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() };
let max_dt = match (across, st.profile.is_some()) {
+4 -4
View File
@@ -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| {
+18 -15
View File
@@ -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, &region) {
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);
+2 -2
View File
@@ -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,
+60
View File
@@ -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");
}
+2 -2
View File
@@ -57,7 +57,7 @@ fn cells(px: f64) -> u16 {
fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: &[Range<u16>; 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<u16>; 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 }
+37
View File
@@ -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]);
}
+13 -11
View File
@@ -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<String> = 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 `<image>` 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 `<image>` 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 };
+6
View File
@@ -37,6 +37,12 @@
//! describes dither as the gradient converted to pixels with subtle noise
//! (<https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html>).
//! On characters the gradient stays a vector gradient, without dither (with a warning).
//! * **Perceptual gradients** fill or stroke objects as an `<image>` 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
//! (<https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html>).
//! On characters they are written as linear gradients through stops on the perceptual curve.
//! * **Symbol instances** the shared `<symbol>` 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.
+87
View File
@@ -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("<image ") && svg.contains("<linearGradient"), "{svg}");
}
/// Blue to yellow, left to right, mixed `method`'s way.
fn blue_yellow(method: vectorcraft_color::GradientMethod) -> 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("<image ").count(), 2, "the fill and the stroke: {svg}");
assert!(!svg.contains("<linearGradient"), "{svg}");
assert!(o.warnings.iter().any(|w| w.contains("drawn as pixels are written as images")), "{:?}", o.warnings);
// The fill's image: two pixels a point at 144 ppi, the middle a blue-grey (OKLab), not the
// mid grey a display RGB mix gives.
let png = png_of(svg);
assert_eq!(png.dimensions(), (320, 240));
let [r, g, b, a] = png.get_pixel(160, 120).0.map(i32::from);
assert_eq!(a, 255);
assert!(b > 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("<image ") && svg.matches("<linearGradient").count() == 2, "{svg}");
}
/// A dithered Perceptual gradient: its fill is an image of the perceptual mix with dither's noise
/// (within a colour level or so of the mix, and not the undithered pixels), its stroke an image of
/// the perceptual mix without noise (dither applies to fills only).
#[test]
fn a_dithered_perceptual_fill_is_the_perceptual_mix_with_noise() {
use vectorcraft_color::GradientMethod;
let paint = |dither| {
let Paint::Gradient(mut g) = blue_yellow(GradientMethod::Perceptual) else { panic!("a gradient") };
g.gradient.dither = dither;
Paint::Gradient(g)
};
let png = |dither, stroke: bool| {
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
let (fill, stroke) = if stroke { (Paint::None, paint(dither)) } else { (paint(dither), Paint::None) };
let mut d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(fill, stroke, 4.0)));
d.raster_effects_ppi = 144.0;
let svg = export_full(&d, &ExportOptions::default(), None).svg;
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
assert!(!svg.contains("<linearGradient"), "{svg}");
png_of(&svg)
};
let (dithered, plain) = (png(true, false), png(false, false));
assert_eq!(dithered.dimensions(), (320, 240));
let Paint::Gradient(want) = paint(false) else { panic!("a gradient") };
let mut noisy = 0;
for x in 0..320 {
let (p, q) = (dithered.get_pixel(x, 120).0, plain.get_pixel(x, 120).0);
let w = want.gradient.sample((x as f32 + 0.5) / 320.0).0.to_rgb();
for k in 0..3 {
let off = (f32::from(p[k]) - w[k].clamp(0.0, 1.0) * 255.0).abs();
assert!(off <= 2.0, "column {x}, channel {k}: {p:?} vs {w:?}");
}
noisy += usize::from(p != q);
}
assert!(noisy > 100, "dither's noise: {noisy} of 320 pixels differ");
assert_eq!(png(true, true), png(false, true), "a stroke has no dither");
}