Merge pull request #3684 from Taha-Mahmoodi/feat/gradient-perceptual

Gradients: Method › Perceptual mixes a gradient's colours in OKLab; SVG and PDF write it as an image
This commit is contained in:
BFlat authored and GitHub committed 2026-10-23 02:06:14 +07:00
commit 44e1c3af62
49 files changed
+716 -72

No files matched your search

+275 -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,139 @@ 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 in a perceptually even way
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
/// Here that is OKLab: blue → yellow passes through a light blue-grey, 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()) {
+9 -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,8 +745,13 @@ 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)];
// A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops
// its expansion adds, so the linear mix between columns follows that curve.
if !g.method.is_classic() {
v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at));
}
for (i, s) in g.stops.iter().enumerate() {
let o = s.offset as f64;
if o > t0 && o < t1 {
@@ -844,7 +849,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| {
+14 -2
View File
@@ -1,7 +1,7 @@
//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector.
use serde_json::{Value, json};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::appearance::stroke_paint_bounds;
use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode};
use vectorcraft_geom::{Affine, Point, Rect};
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
@@ -105,6 +105,14 @@ fn parse_kind(k: &str) -> Parsed<GradientKind> {
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
}
/// The `method` param of `p`, if given: how the colours between the stops mix.
fn method_param(p: &Value) -> Parsed<Option<GradientMethod>> {
match p.get("method") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()),
}
}
/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d`
/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and
/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps
@@ -229,6 +237,7 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or("`dither` must be true or false")?,
};
gp.gradient.method = method_param(g)?.unwrap_or_default();
gp.angle = f64_or(g, "angle", 0.0);
gp.swatch = str_param(g, "swatch").map(str::to_string);
let geom = GeomEdit::parse(g)?;
@@ -326,6 +335,9 @@ pub(crate) fn apply_gradient_edit_in(
if let Some(d) = p.get("dither").and_then(Value::as_bool) {
gp.gradient.dither = d;
}
if let Some(m) = method_param(p)? {
gp.gradient.method = m;
}
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
let before = gp.geom;
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
+1 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
has_doc,
|s, p| set_paint(s, p, true)
),
+57
View File
@@ -467,3 +467,60 @@ fn an_outlined_stroke_leaves_dither_behind() {
});
assert_eq!(fills, [false, false], "the fill and the outline, neither dithered");
}
/// The Gradient panel's Method (Illustrator 30): `method` set and cleared through the gradient
/// commands, on fills and strokes, kept by the params, swatches and a native save and load;
/// anything but classic or perceptual is refused without editing.
#[test]
fn method_is_a_gradient_option_that_saves_and_loads() {
use vectorcraft_color::GradientMethod;
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let stops = json!([{"offset": 0, "color": "#0000ff"}, {"offset": 1, "color": "#ffff00"}]);
s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap();
assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::Classic, "classic by default");
let undo = s.doc().unwrap().history.undo.len();
s.execute("paint.editGradient", &json!({"method": "perceptual"})).unwrap();
let g = fill_gradient(&s, id);
assert_eq!(g.gradient.method, GradientMethod::Perceptual);
assert_eq!(g.gradient.stops.len(), 2, "the stops stay as they were");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The params carry it, so a swatch and a copied paint keep it.
assert_eq!(vectorcraft_tools::params::gradient_params(&g)["method"], json!("perceptual"));
let mut q = gradient_param(&g);
q["name"] = json!("Even");
s.execute("swatch.new", &q).unwrap();
let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch("Even").map(|w| w.paint.clone()) else { panic!("a gradient swatch") };
assert_eq!(sw.gradient.method, GradientMethod::Perceptual);
// Saved and opened again.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
assert_eq!(saved.gradient.method, GradientMethod::Perceptual, "the native file keeps the method");
// Strokes take it too.
s.execute("paint.setStroke", &json!({"gradient": {"method": "perceptual"}})).unwrap();
let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
assert_eq!(st.gradient.method, GradientMethod::Perceptual);
s.execute("paint.editGradient", &json!({"method": "classic", "stroke": true})).unwrap();
let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") };
assert_eq!(st.gradient.method, GradientMethod::Classic);
// Bad values: refused, nothing changed.
let before = node(&s, id).appearance.clone();
for bad in [json!("smooth"), json!(1), json!(true)] {
let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string();
assert!(e.contains("`method` must be classic or perceptual"), "{e}");
assert!(s.execute("paint.setFill", &json!({"gradient": {"method": bad}})).is_err());
}
assert_eq!(node(&s, id).appearance, before);
s.execute("paint.editGradient", &json!({"method": "Classic", "stroke": false})).unwrap();
assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::Classic);
assert!(vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)).get("method").is_none());
// With dither: one edit sets both, and the params carry both back.
s.execute("paint.editGradient", &json!({"method": "perceptual", "dither": true, "stroke": false})).unwrap();
let g = fill_gradient(&s, id);
assert!(g.gradient.dither && g.gradient.method == GradientMethod::Perceptual);
let p = vectorcraft_tools::params::gradient_params(&g);
assert_eq!((&p["method"], &p["dither"]), (&json!("perceptual"), &json!(true)));
s.execute("paint.setFill", &json!({"gradient": p})).unwrap();
let back = fill_gradient(&s, id);
assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual);
}
+2
View File
@@ -197,6 +197,8 @@ fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
for gradient in [
json!({"kind": "linear", "angle": 30, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.4, "color": "#00ff00", "midpoint": 0.3}, {"offset": 1, "color": "#0000ff"}]}),
json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]}),
// Perceptual: the mesh follows the OKLab curve between the stops, not a straight RGB mix.
json!({"kind": "linear", "method": "perceptual", "stops": [{"offset": 0, "color": "#2050f0"}, {"offset": 1, "color": "#f5d020"}]}),
] {
let mut s = session();
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient.clone(), false);
+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");
}
+3
View File
@@ -72,6 +72,9 @@ pub fn gradient_params(g: &GradientPaint) -> Value {
if g.gradient.dither {
v["dither"] = json!(true);
}
if !g.gradient.method.is_classic() {
v["method"] = json!(g.gradient.method.label().to_lowercase());
}
v
}
+1
View File
@@ -5388,3 +5388,4 @@ axis Y Axis Y ос
@msg `dither` applies to gradient fills (stroke: false) `dither` се прилага към градиентни запълки (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` се прилага към градиентни запълки (paint.setFill)
@msg `dither` must be true or false `dither` трябва да бъде true или false
@msg `method` must be classic or perceptual `method` трябва да е classic или perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5433,3 +5433,4 @@ axis Y Axis osa Y
@msg `dither` applies to gradient fills (stroke: false) `dither` platí pro výplně přechodem (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill)
@msg `dither` must be true or false `dither` musí být true nebo false
@msg `method` must be classic or perceptual `method` musí být classic nebo perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5404,3 +5404,4 @@ axis Y Axis Y-akse
@msg `dither` applies to gradient fills (stroke: false) `dither` gælder for gradientfyld (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` gælder for gradientfyld (paint.setFill)
@msg `dither` must be true or false `dither` skal være true eller false
@msg `method` must be classic or perceptual `method` skal være classic eller perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5015,3 +5015,5 @@ axis Y Axis Y-Achse
@msg `dither` applies to gradient fills (stroke: false) `dither` gilt für Verlaufsflächen (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` gilt für Verlaufsflächen (paint.setFill)
@msg `dither` must be true or false `dither` muss true oder false sein
Perceptual Perzeptiv
@msg `method` must be classic or perceptual `method` muss classic oder perceptual sein
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5385,3 +5385,4 @@ axis Y Axis Άξονας Y
@msg `dither` applies to gradient fills (stroke: false) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill)
@msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false
@msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual
Can't render this file because it contains an unexpected character in line 2045 and column 19.
+1
View File
@@ -5375,3 +5375,4 @@ axis Y Axis Eje Y
@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a rellenos de degradado (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill)
@msg `dither` must be true or false `dither` debe ser true o false
@msg `method` must be classic or perceptual `method` debe ser classic o perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5464,3 +5464,4 @@ axis Y Axis Y-akseli
@msg `dither` applies to gradient fills (stroke: false) `dither` koskee liukuväritäyttöjä (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill)
@msg `dither` must be true or false `dither` on oltava true tai false
@msg `method` must be classic or perceptual `method` on oltava classic tai perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5432,3 +5432,4 @@ axis Y Axis Axe Y
@msg `dither` applies to gradient fills (stroke: false) `dither` s’applique aux fonds en dégradé (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill)
@msg `dither` must be true or false `dither` doit valoir true ou false
@msg `method` must be classic or perceptual `method` doit être classic ou perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5419,3 +5419,4 @@ axis Y Axis Y tengely
@msg `dither` applies to gradient fills (stroke: false) A `dither` színátmenetes kitöltésekre vonatkozik (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill)
@msg `dither` must be true or false `dither`: true vagy false kell
@msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5404,3 +5404,4 @@ axis Y Axis Sumbu Y
@msg `dither` applies to gradient fills (stroke: false) `dither` berlaku untuk isian gradien (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` berlaku untuk isian gradien (paint.setFill)
@msg `dither` must be true or false `dither` harus true atau false
@msg `method` must be classic or perceptual `method` harus classic atau perceptual
Can't render this file because it contains an unexpected character in line 3816 and column 19.
+2
View File
@@ -5049,3 +5049,5 @@ axis Y Axis Asse Y
@msg `dither` applies to gradient fills (stroke: false) `dither` si applica ai riempimenti sfumati (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` si applica ai riempimenti sfumati (paint.setFill)
@msg `dither` must be true or false `dither` deve essere true o false
Perceptual Percettivo
@msg `method` must be classic or perceptual `method` deve essere classic o perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5111,3 +5111,5 @@ axis Y Axis Y 軸
@msg `dither` applies to gradient fills (stroke: false) `dither` はグラデーションの塗りに適用されます (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` はグラデーションの塗りに適用されます (paint.setFill)
@msg `dither` must be true or false `dither` には true または false を指定してください
Perceptual 知覚的
@msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5349,3 +5349,4 @@ axis Y Axis Y ღერძი
@msg `dither` applies to gradient fills (stroke: false) `dither` ვრცელდება გრადიენტულ შევსებებზე (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill)
@msg `dither` must be true or false `dither` უნდა იყოს true ან false
@msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5012,3 +5012,5 @@ axis Y Axis Y축
@msg `dither` applies to gradient fills (stroke: false) `dither`는 그레이디언트 칠에 적용됩니다 (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither`는 그레이디언트 칠에 적용됩니다 (paint.setFill)
@msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다
Perceptual 지각적
@msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5447,3 +5447,4 @@ axis Y Axis Y-as
@msg `dither` applies to gradient fills (stroke: false) `dither` geldt voor verloopvullingen (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` geldt voor verloopvullingen (paint.setFill)
@msg `dither` must be true or false `dither` moet true of false zijn
@msg `method` must be classic or perceptual `method` moet classic of perceptual zijn
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5285,3 +5285,4 @@ axis Y Axis Oś Y
@msg `dither` applies to gradient fills (stroke: false) `dither` dotyczy wypełnień gradientem (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill)
@msg `dither` must be true or false `dither` musi mieć wartość true lub false
@msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5350,3 +5350,4 @@ axis Y Axis Eixo Y
@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a preenchimentos em gradiente (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill)
@msg `dither` must be true or false `dither` deve ser true ou false
@msg `method` must be classic or perceptual `method` deve ser classic ou perceptual
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -5045,3 +5045,5 @@ axis Y Axis Ось Y
@msg `dither` applies to gradient fills (stroke: false) `dither` применяется к градиентным заливкам (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` применяется к градиентным заливкам (paint.setFill)
@msg `dither` must be true or false `dither` должно быть true или false
Perceptual Перцепционный
@msg `method` must be classic or perceptual `method` должен быть classic или perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5389,3 +5389,4 @@ axis Y Axis Y-axel
@msg `dither` applies to gradient fills (stroke: false) `dither` gäller övertoningsfyllningar (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` gäller övertoningsfyllningar (paint.setFill)
@msg `dither` must be true or false `dither` måste vara true eller false
@msg `method` must be classic or perceptual `method` måste vara classic eller perceptual
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -5397,3 +5397,4 @@ axis Y Axis Y ekseni
@msg `dither` applies to gradient fills (stroke: false) `dither` degrade dolgulara uygulanır (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` degrade dolgulara uygulanır (paint.setFill)
@msg `dither` must be true or false "dither" doğru veya yanlış olmalıdır
@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -5029,3 +5029,5 @@ axis Y Axis Вісь Y
@msg `dither` applies to gradient fills (stroke: false) `dither` застосовується до градієнтних заливок (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` застосовується до градієнтних заливок (paint.setFill)
@msg `dither` must be true or false `dither` має бути true або false
Perceptual Перцептивний
@msg `method` must be classic or perceptual `method` має бути classic або perceptual
Can't render this file because it contains an unexpected character in line 733 and column 40.
+2
View File
@@ -5029,3 +5029,5 @@ axis Y Axis Trục Y
@msg `dither` applies to gradient fills (stroke: false) `dither` áp dụng cho vùng tô chuyển sắc (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` áp dụng cho vùng tô chuyển sắc (paint.setFill)
@msg `dither` must be true or false `dither` phải là true hoặc false
Perceptual Theo cảm nhận
@msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual
Can't render this file because it contains an unexpected character in line 4028 and column 10.
+1
View File
@@ -5273,3 +5273,4 @@ axis Y Axis Y 轴
@msg `dither` applies to gradient fills (stroke: false) `dither` 适用于渐变填色 (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill)
@msg `dither` must be true or false `dither` 必须是 true 或 false
@msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5068,3 +5068,5 @@ axis Y Axis Y 軸
@msg `dither` applies to gradient fills (stroke: false) `dither` 適用於漸層填色 (stroke: false)
@msg `dither` applies to gradient fills (paint.setFill) `dither` 適用於漸層填色 (paint.setFill)
@msg `dither` must be true or false `dither` 必須是 true 或 false
Perceptual 感知
@msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual
Can't render this file because it contains an unexpected character in line 6 and column 47.
+39 -1
View File
@@ -23,7 +23,7 @@ use serde_json::{Value, json};
use vectorcraft_color::gradient::{
MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors,
};
use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_color::{Color, Freeform, FreeformMode, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{NodeId, StrokeGradientMode};
use super::{active_paint, live_run, pstate, set_pstate};
@@ -287,6 +287,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
freeform_section(app, ui, &g);
return;
}
method_row(app, ui, &g, is_grad);
// The proxy beside the angle and aspect ratio fields.
ui.horizontal(|ui| {
super::proxy(app, ui, 36.0);
@@ -334,6 +335,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// The Method row (Illustrator 30): how a linear or radial gradient mixes the colours between its
/// stops, Classic or Perceptual.
fn method_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) {
let methods = [(GradientMethod::Classic, tl!("Classic")), (GradientMethod::Perceptual, tl!("Perceptual"))];
let current = g.gradient.method;
let shown = methods.iter().find(|(m, _)| *m == current).map_or("", |(_, l)| *l);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Method:"));
let labels = methods.map(|(_, l)| l);
let chosen = ui.add_enabled_ui(is_grad, |ui| widgets::dropdown_names(ui, "grad-method", shown, &labels, 104.0)).inner;
if let Some((m, _)) = chosen.and_then(|i| methods.get(i))
&& *m != current
{
edit(app, json!({"method": m.label().to_lowercase()}), Live::Released);
}
});
}
/// The freeform points shown: the gradient's own, else (unplaced) as many as it places, coloured
/// along the stops.
pub(crate) fn shown_points(g: &GradientPaint) -> std::borrow::Cow<'_, Freeform> {
@@ -999,6 +1018,25 @@ mod tests {
assert!(!st.gradient.dither);
}
#[test]
fn the_method_menu_makes_the_gradient_perceptual() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "Method:"), "{texts:?}");
let shown = texts.iter().find(|(t, _)| t == "Classic").expect("Classic shown").1;
let undo = app.session.doc().unwrap().history.undo.len();
let texts = p.click(&mut app, shown);
let option = texts.iter().find(|(t, _)| t == "Perceptual").expect("the menu lists Perceptual").1;
p.click(&mut app, option);
assert_eq!(fill(&app).gradient.method, GradientMethod::Perceptual);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "Perceptual"), "the menu shows the method: {texts:?}");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(fill(&app).gradient.method, GradientMethod::Classic);
}
#[test]
fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]);
+13 -4
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G8: SVG images in SVG images share one depth cap, whatever their data URL type, and SVGZ images one unpacking cap, so a crafted SVG no longer overflows the stack or unpacks gigabytes (#3562, #3563); scores and estimates unchanged; G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged; G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 45/57, 12 remaining (8–14 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified bLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G13: gradient Method (Illustrator 30.0): a Method menu in the Gradient panel, Classic or Perceptual (`paint.editGradient`/`paint.setFill` `method`, saved in the native file only when Perceptual, so older files open as Classic); Perceptual mixes a linear or radial gradient's colours in OKLab, on the canvas and in raster exports, and SVG and PDF exports write it as an image at the Document Raster Effects resolution, as the public documentation describes; Classic draws as before; a dithered Perceptual fill takes dither's noise; G13 estimate 14–24 h → 13–23 h; G8: SVG images in SVG images share one depth cap, whatever their data URL type, and SVGZ images one unpacking cap, so a crafted SVG no longer overflows the stack or unpacks gigabytes (#3562, #3563); scores and estimates unchanged; G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged; G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; G1: one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; G12: Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; transparency grid visibility saved in native documents, #1968; G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification withoLine truncated
## Feature submission evidence
@@ -45,7 +45,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
| 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) |
| 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) |
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 4–6 | no | [effects-parity.md](effects-parity.md) |
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 14–24 | no | [target-app-parity.md](target-app-parity.md) |
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) |
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 8–16 | macOS pressure | [hardware-parity.md](hardware-parity.md) |
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 16–27 | no | [target-app-parity.md](target-app-parity.md) |
| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) |
@@ -311,15 +311,23 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
colour level, which hides banding on the canvas and in raster exports; SVG and PDF exports write the fill as an image at
the Document Raster Effects resolution, and PDF with Preserve Editing keeps the gradient editable. Fills only, as the
[public documentation](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html) says. Freeform gradients ignore it.
- **Done:** gradient Method (30.0): the Gradient panel's Method menu, Classic or Perceptual (`paint.editGradient`
`method`, also in `paint.setFill`'s `gradient`, swatches and the native file, written only when Perceptual). Perceptual
mixes a linear or radial gradient's colours in OKLab, so blue to yellow passes through a light blue-grey rather than
mid grey; renderers draw it through stops on that curve (within half a colour level), and SVG and PDF exports write it
as an image at the Document Raster Effects resolution, as the
[public documentation](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html) says perceptual gradients are rasterised there.
Classic draws exactly as before; freeform gradients mix their own way. With Dither, a Perceptual fill is the
perceptual mix with dither's noise (an image with the noise in SVG and PDF); a stroke has no noise.
- **Missing (asked for by users, from earlier versions):** radial gradients in Image Trace's
Gradients option, which traces linear ones only (draft #1849). Inside the estimate below.
- **Missing:** gradient perceptual blending and gradient presets (30.0); tangent and perpendicular
- **Missing:** gradient presets (30.0); tangent and perpendicular
snapping and the consolidated snapping options (30.0); artboard locking, artboard colors in exports and print, and managing
artboards from the canvas (30.0); the Blend panel with ease in/out and separate colour acceleration (30.7);
the Dimension tool (28.x).
- **Evidence:** Adobe's 30.0 and 30.7 release announcements, see
[target-app-parity.md](target-app-parity.md#how-this-was-measured-2026-10-10).
- **Estimate:** 14–24 h.
- **Estimate:** 13–23 h.
## G14 Pen hardware beyond Windows pressure
@@ -450,6 +458,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | G13: gradient Method (30.0), Classic or Perceptual, in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports (with Dither, a Perceptual fill takes its noise); G13 estimate 14–24 h → 13–23 h. |
| 2026-10-22 | minor | G8: SVG images in SVG images share the depth cap (any data URL type usvg reads as SVG) and SVGZ images one 512 MB unpacking cap; past either, an image is left out with a warning (#3562, #3563). No entry closed, estimates unchanged. |
| 2026-10-22 | minor | G19: PDF export asks viewers to display the document title only when File Info has a Document Title or an explicit export title is supplied; untitled documents keep the PDF file name in the viewer title bar (#3646); scores and estimates unchanged. |
| 2026-10-22 | minor | G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged. |
+9
View File
@@ -790,6 +790,15 @@ SVG and PDF exports write such a fill as an image at the Document Raster Effects
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"dither":true}}}
```
Method (the Gradient panel's menu): `classic` (the default) mixes the colours between stops in display RGB,
`perceptual` mixes a linear or radial gradient's colours in OKLab so the steps look even. SVG and PDF exports write a
Perceptual gradient as an image at the Document Raster Effects resolution (a dithered Perceptual fill with its noise).
`paint.setFill`/`paint.setStroke` take it in `gradient` too:
```json
{"name":"run_command","arguments":{"command":"paint.editGradient","params":{"method":"perceptual"}}}
```
Freeform gradients: `paint.editGradient {kind: "freeform"}` places four or more colour points inside each selected
object (coloured along the stops; `mode: "points"|"lines"` is the Draw toggle). `paint.freeform.get` lists the points
(document coordinates), lines and the selected point; `paint.freeform.addPoint {at, color?, opacity?, spread?,
+1 -1
View File
@@ -198,7 +198,7 @@ type and `blob` but without their data, which follows the JSON in a saved file;
`paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`)
and `miterLimit`. Paints use the
native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient":
{"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}], "dither"?}, "geom"?, "angle",
{"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}], "dither"?, "method"?: "Perceptual"}, "geom"?, "angle",
"freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk",
c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except
Lab's.
+22 -3
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images, see [type-parity.md](type-parity.md); scores unchanged; imported PDF type keeps a word space between two sizes (#3623); G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged; #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocatioLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G13: gradient Method (Illustrator 30.0): a Method menu in the Gradient panel, Classic or Perceptual (`paint.editGradient`/`paint.setFill` `method`, saved in the native file only when Perceptual, so older files open as Classic); Perceptual mixes a linear or radial gradient's colours in OKLab, on the canvas and in raster exports, and SVG and PDF exports write it as an image at the Document Raster Effects resolution, as the public documentation describes; Classic draws as before; a dithered Perceptual fill takes dither's noise; score and hours unchanged; colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images, see [type-parity.md](type-parity.md); scores unchanged; imported PDF type keeps a word space between two sizes (#3623); G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged; #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror ReLine truncated
## Feature submission evidence
@@ -95,9 +95,9 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; remaining: Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated |
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool, Live Corners' Relative rounding; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937), its gap detection and options; Live Paint Gap Options; Simplify slider/auto, advanced UI and Show Original | 4–8 | estimated (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Divide open-cutting-path geometry and appearance regression-tested (#1307) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's perceptual blending and gradient presets (gradient dither is done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient presets (gradient dither and perceptual blending are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options; Simplify slider/auto, advanced UI and Show Original | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Divide open-cutting-path geometry and appearance regression-tested (#1307) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's perceptual blending and gradient presets (gradient dither is done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient presets (gradient dither and perceptual blending are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; remaining: Art brush Overlap, Pattern brush auto corners, brush libraries, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) |
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 46/50, depth estimated; Warp Options controls verified by focused checks and original-image review |
@@ -996,10 +996,29 @@ keeps the gradient editable. Strokes and freeform gradients ignore it (the docum
`svg/tests/raster_paints.rs`, `pdf::tests::dithered_gradient_fills_export_as_images_and_strokes_as_shadings` and the
Gradient panel's `the_dither_box_dithers_the_gradient_fill_and_is_off_for_strokes`. Scores and hours unchanged.
## Gradient Method: Classic and Perceptual (2026-10-22)
Illustrator 30.0's gradient Method ([public documentation](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html)): the Gradient panel's Method menu sets
`method` on the gradient, Classic or Perceptual (`paint.editGradient`, `paint.setFill`'s `gradient`, swatches, the native
file; written only when Perceptual, so older files open as Classic). The documentation describes perceptual interpolation
as colour transitions matched to how the eye perceives differences between colours; VectorCraft mixes a Perceptual
gradient's colours in OKLab (lightness steps even, blue to yellow through a light blue-grey rather than mid grey), and
renderers that mix linearly draw it through stops placed on that curve, more where it bends, within half a colour level.
As documented, SVG and PDF exports rasterise it: fills and strokes are written as images at the Document Raster Effects
resolution, clipped to what they paint (a gradient along or across a stroke as slices through the curve's stops). Classic
gradients draw exactly as before. Freeform gradients mix their own way. With Dither, a Perceptual fill is the perceptual
mix with dither's noise, on the canvas and in the SVG and PDF images; a Perceptual stroke's image has no noise (dither
applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansion, Classic unchanged, save format),
`engine::tests_gradient::method_is_a_gradient_option_that_saves_and_loads`, `render::tests_dither`,
`svg/tests/raster_paints.rs`, `pdf::tests::perceptual_gradients_export_as_images_and_classic_ones_as_shadings`,
`pdf::tests::dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill` and the Gradient panel's
`the_method_menu_makes_the_gradient_perceptual`. Scores and hours unchanged.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Method, Classic or Perceptual (Gradient panel menu, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). |
| 2026-10-22 | minor | Colour and gradients: Illustrator 30.0's gradient Dither (Gradient panel box, canvas and raster exports, SVG/PDF as images at the raster effects resolution, native file); score and hours unchanged (estimated). |
| 2026-10-22 | minor | #3546: `--automation-read-root` and `--automation-write-root` may each be given more than once (`vectorcraft-cli mcp` and the desktop app); a path inside any root of its kind is allowed, every confinement check unchanged. Scores and estimates unchanged. |
| 2026-10-22 | minor | #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged. |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G13: gradient Method (Illustrator 30.0): a Method menu in the Gradient panel, Classic or Perceptual (`paint.editGradient`/`paint.setFill` `method`, saved in the native file only when Perceptual, so older files open as Classic); Perceptual mixes a linear or radial gradient's colours in OKLab, on the canvas and in raster exports, and SVG and PDF exports write it as an image at the Document Raster Effects resolution, as the public documentation describes; Classic draws as before; a dithered Perceptual fill takes dither's noise; score and hours unchanged; G13: gradient Dither (Illustrator 30.0): a Dither box in the Gradient panel (`paint.editGradient`/`paint.setFill` `dither`, saved in the native file) draws a linear or radial gradient fill with seeded fine noise that hides banding, on the canvas and in raster exports; SVG and PDF exports write the fill as an image at the Document Raster Effects resolution, as the public documentation describes dither; fills only, as documented; score and hours unchanged; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; Panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; one selected clip group's Properties panel has Edit Clipping Path and Edit Contents at its top, G1; Distribute Objects and Distribute Spacing space from a key guide (#3165) with the Distribute Spacing value; Distribute spaces selected ruler guides with the objects, #1597 in part; the selection tools take a clipping path on its outline, over the art it clips; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; a ruler guide can be the key object Align aligns to, and selected guides align to a key object, #2143; Pencil: Alt key toggles to Smooth Tool, #2001; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; on-canvas Mirror Repeat axis, #2877; Background Export progress dialog with Cancel, #2564; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; scores unchanged; library panels: one per library, dockable and tabbable, with Persistent, #2603; the Selection tool threads text from the out port, #2572 in part; scores unchanged; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog opens on the last move and its last checkboxes; scores unchanged; transparency grid visibility saved in native documents, #1968; Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14Line truncated
## Feature submission evidence
@@ -273,6 +273,7 @@ empty), without opening the dialog.
| Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
| Blend panel (30.7) | partial | Window › Blend provides Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift, alongside the existing blend controls. Easing is disabled without a spine. Original polynomial distributions approximate the publicly documented behavior; on-canvas easing handles remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) |
| Gradient panel: Dither (30.0) | done | Dither box under the stops (`paint.editGradient` `dither`); dimmed behind the Stroke proxy, fills only; `panels::gradient` test |
| Gradient panel: Method, Classic or Perceptual (30.0) | done | Method menu under the Stroke buttons for linear and radial gradients (`paint.editGradient` `method`); [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html); `panels::gradient` test |
| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
| Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset <name> | done | `UiState::workspace_changes` |
| Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet |
@@ -320,6 +321,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Gradient panel: the Method menu, Classic or Perceptual (Illustrator 30.0); scores and estimates unchanged. |
| 2026-10-22 | minor | Gradient panel: the Dither box (Illustrator 30.0); scores and estimates unchanged. |
| 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. |
| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. |