mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
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:
committed by
bflatastic
parent
e195fc94e2
commit
2bad646821
+276
-19
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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) }
|
||||
});
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user