PDF export: a Perceptual gradient that mixes spot and process inks keeps its inks

A Perceptual linear or radial gradient that mixes spot colors with each
other or with CMYK or gray stops was written as an image in process
colors. It is now written as a DeviceN shading of its inks, with the
stops on its Perceptual curve: at each one, the inks of the two stops on
either side are mixed at the weight where the two colors mixed in
display RGB have the OKLab lightness of the color on the curve.

- Gradient::ink_mixes gives the curve's stops as weights between stops,
  under the same 4,096-stop limit as ink_tints.
- Perceptual gradients that mix a spot color with RGB or Lab stops,
  dithered fills and freeform gradients stay images, and the warning
  now names those cases.
This commit is contained in:
dotCooCoo
2026-11-01 11:09:09 +07:00
committed by bflatastic
parent 7e5e099f8e
commit 38bb213917
7 changed files with 180 additions and 29 deletions
+67 -14
View File
@@ -108,6 +108,30 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
}
}
/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec<f32> {
let mut vs = Vec::new();
perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
vs
}
/// The offset of mix position `v` between stops `a` and `b`, placed through `a`'s midpoint
/// (`v` = ½ at the midpoint) as [`Gradient::sample`] maps it.
fn curve_offset(a: &GradientStop, b: &GradientStop, v: f32) -> f32 {
let m = a.midpoint.clamp(0.01, 0.99);
let u = if v == 0.5 {
a.midpoint
} else if v < 0.5 {
2.0 * v * m
} else {
m + (v - 0.5) * 2.0 * (1.0 - m)
};
a.offset + (b.offset - a.offset) * u
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
@@ -270,21 +294,9 @@ impl Gradient {
// 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| {
perceptual_mixes(s, n).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 t = curve_offset(s, n, v);
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))
})
@@ -329,6 +341,47 @@ impl Gradient {
pub fn fits_ink_tints(&self) -> bool {
self.expanded().nth(MAX_INK_TINTS).is_none()
}
/// A Perceptual gradient's curve ([`Self::expanded_stops`]) as mixes of its stops' inks:
/// (offset, `i`, weight, opacity) at each stop on it, where the inks there are those of stop
/// `i` and stop `i + 1` mixed linearly at the weight (0..1; 0 on the gradient's own stops).
/// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab
/// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option<Vec<(f32, usize, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
if self.method.is_classic() || !self.fits_ink_tints() {
return None;
}
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
let mut out = Vec::new();
for (i, s) in self.stops.iter().enumerate() {
out.push((s.offset, i, 0.0, s.opacity));
let Some(n) = self.stops.get(i + 1) else { continue };
let (l0, l1) = (lightness(&s.color), lightness(&n.color));
for v in perceptual_mixes(s, n) {
let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN {
v
} else {
let want = lightness(&self.method.mix(&s.color, &n.color, v));
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..TINT_STEPS {
let mid = (lo + hi) / 2.0;
// The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one.
if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
};
out.push((curve_offset(s, n, v), i, weight, s.opacity + (n.opacity - s.opacity) * v));
}
}
Some(out)
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
+37
View File
@@ -486,3 +486,40 @@ fn ink_tints_follow_the_perceptual_curve() {
assert_eq!(g.ink_tints(&ink), None);
assert!(!g.fits_ink_tints());
}
/// A Perceptual gradient's curve as mixes of its stops' inks: the stops on the curve at their
/// offsets, each weighted so the two stops' colors mixed in display RGB have the lightness of
/// the color on the curve there.
#[test]
fn ink_mixes_follow_the_perceptual_curve() {
use crate::libraries::srgb_to_oklab;
let (magenta, yellow) = (Color::cmyk(0.0, 1.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.6, 0.0));
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, magenta), GradientStop::new(1.0, yellow)]);
g.stops[0].midpoint = 0.3;
assert_eq!(g.ink_mixes(), None, "a Classic gradient");
g.method = GradientMethod::Perceptual;
let mixes = g.ink_mixes().expect("a Perceptual gradient's curve");
let curve = g.expanded_stops();
assert_eq!(mixes.len(), curve.len(), "a mix for each stop on the curve");
assert_eq!((mixes[0], mixes[mixes.len() - 1]), ((0.0, 0, 0.0, 1.0), (1.0, 1, 0.0, 1.0)), "the gradient's own stops");
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
for ((t, i, w, _), (ct, c, _)) in mixes.iter().zip(&curve) {
assert!((t - ct).abs() < 1e-6, "{t} on the curve at {ct}");
if *i == 0 && *t > 0.0 {
let mixed = GradientMethod::Classic.mix(&magenta, &yellow, *w);
assert!((lightness(&mixed) - lightness(c)).abs() < 1e-3, "at {t}: weight {w}");
}
}
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
assert!((mid.2 - 0.5).abs() > 0.01, "not halfway at the midpoint: {mid:?}");
assert!(mixes.windows(2).all(|w| w[0].0 <= w[1].0), "in order: {mixes:?}");
// Two colors of one lightness: the weights are the mix positions.
g.stops[1].color = magenta;
let mixes = g.ink_mixes().expect("the curve");
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
assert!((mid.2 - 0.5).abs() < 1e-6, "{mid:?}");
// More stops on the curve than MAX_INK_TINTS: no mixes.
let n = MAX_INK_TINTS + 1;
g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect();
assert_eq!(g.ink_mixes(), None);
}
+1 -1
View File
@@ -23,7 +23,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), 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, except that PDF writes one made of tints of one spot color in that ink; 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",
"{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, except that PDF writes one with spot color stops in its inks when every other stop is paper white, CMYK or gray; 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
),
+30 -2
View File
@@ -236,11 +236,39 @@ fn perceptual_spot_gradients_export_as_separation_shadings() {
assert!(pdf.contains("/C0[1]") && pdf.contains("/C1[0.4"), "white ink at 100% to 40%: {}", &pdf[at..(at + 200).min(pdf.len())]);
}
#[test]
fn perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() {
let (mut s, _) = setup();
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 1, "m": 0, "y": 1, "k": 0}, "spot": true}));
let ink_to_cyan = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}]});
let ink_to_leaf = json!({"stops": [{"offset": 0, "swatch": "Ink", "midpoint": 30}, {"offset": 1, "swatch": "Leaf", "tint": 50}]});
for (what, edit, space) in [
("Ink → cyan", with(ink_to_cyan.clone(), json!({"method": "perceptual"})), "/DeviceN[/Cyan/Ink]/DeviceCMYK"),
("Ink → Leaf 50 %", with(ink_to_leaf, json!({"method": "perceptual"})), "/DeviceN[/Ink/Leaf]/DeviceCMYK"),
] {
run(&mut s, "paint.editGradient", edit);
let (pdf, warnings) = pdf_of(&s);
assert!(!pdf.contains("/Subtype/Image"), "{what}: no image");
assert!(pdf.contains("/ShadingType 2") && pdf.contains(space), "{what}: a DeviceN shading");
// The curve's stops: a stitching function of more than the two ends.
assert!(pdf.contains("/FunctionType 3"), "{what}: inks on the curve");
assert!(!warnings.iter().any(|w| w.contains("spot")), "{what}: {warnings:?}");
}
// The curve starts at the first stop's inks: no cyan, Ink at 100 %.
run(&mut s, "paint.editGradient", with(ink_to_cyan.clone(), json!({"method": "perceptual"})));
let (pdf, _) = pdf_of(&s);
assert!(pdf.contains("/C0[0 1]"), "the first stop's inks");
// A radial one too.
run(&mut s, "paint.editGradient", with(ink_to_cyan, json!({"method": "perceptual", "kind": "radial"})));
let (pdf, _) = pdf_of(&s);
assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType 3") && pdf.contains("/DeviceN[/Cyan/Ink]/DeviceCMYK"), "radial");
}
#[test]
fn spot_gradients_drawn_as_images_warn() {
let (mut s, _) = setup();
let spot_warning = |w: &[String]| w.iter().any(|w| w.contains("spot colors in gradients exported as images"));
let ink_to_cyan = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}]});
let ink_to_green = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "color": "#00ff00"}]});
// More stops than the export finds tints for: an image, as before.
let n = 4097;
let stops: Vec<Value> = (0..n)
@@ -250,7 +278,7 @@ fn spot_gradients_drawn_as_images_warn() {
})
.collect();
for (what, edit) in [
("Perceptual Ink → cyan", with(ink_to_cyan, json!({"method": "perceptual"}))),
("Perceptual Ink → RGB green", with(ink_to_green, json!({"method": "perceptual"}))),
("dithered Classic Ink → paper", with(ink_to_white(100.0), json!({"dither": true, "method": "classic"}))),
("dithered Perceptual Ink → paper", with(ink_to_white(100.0), json!({"dither": true, "method": "perceptual"}))),
("Perceptual Ink and paper, 4,097 stops", json!({"stops": stops, "dither": false, "method": "perceptual"})),
+2 -2
View File
@@ -348,8 +348,8 @@ impl Exporter<'_> {
/// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which
/// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels
/// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of tints
/// of one spot ink ([`Self::perceptual_spot`])?
/// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of spot
/// inks ([`Self::perceptual_spot`])?
pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool {
match p {
Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(),
+42 -9
View File
@@ -100,9 +100,10 @@ impl Exporter<'_> {
/// The stops of a gradient that mixes spot inks with process colors or with each other, in
/// a DeviceN space of the process inks the stops use (in CMYK order) and the spot inks (in
/// the order the stops name them), with midpoints as explicit stops (the tints and opacity
/// there are halfway). Paper white adds no ink. `None`, with a warning, when a stop is
/// written in RGB or Lab or the spot inks can't share a DeviceN space; the gradient is then
/// written in the colors of its stops.
/// there are halfway), or for a Perceptual gradient the stops on its curve
/// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink. `None`, with a
/// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space;
/// the gradient is then written in the colors of its stops.
fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process};
let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3];
@@ -151,6 +152,17 @@ impl Exporter<'_> {
let tints = process.iter().map(|&i| q(v[i])).chain(v[4..].iter().map(|&t| q(t))).collect();
Some(DnColor::new(tints, space.clone())?.into())
};
// A Perceptual gradient: the stops on its curve, each a mix of two stops' inks.
if let Some(curve) = g.ink_mixes() {
return curve
.into_iter()
.map(|(o, i, w, opacity)| {
let next = inks.get(i + 1).unwrap_or(&inks[i]);
let mixed: Vec<f32> = inks[i].iter().zip(next).map(|(a, b)| a + (b - a) * w).collect();
Some((o, color(&mixed)?, opacity))
})
.collect();
}
let mut out = vec![];
for (i, s) in g.stops.iter().enumerate() {
out.push((s.offset, color(&inks[i])?, s.opacity));
@@ -345,13 +357,32 @@ impl Exporter<'_> {
g.stops.iter().all(|s| spot(s) == Some(ink) || paper(s)).then_some(ink)
}
/// Is `g` a Perceptual linear or radial gradient of tints of one spot ink that paints a fill
/// (`fill`) without dither or a stroke, with a curve short enough for
/// [`vectorcraft_color::Gradient::fits_ink_tints`], which [`Self::perceptual_spot_stops`]
/// writes as a Separation shading rather than an image?
/// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop
/// linked to a spot swatch, and is every other stop paper white, unlinked, or written in CMYK
/// or gray?
fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool {
let doc = self.doc;
let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
let spot = |s: &vectorcraft_color::GradientStop| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot));
let inks = |s: &vectorcraft_color::GradientStop| {
(s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3])
|| matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. })
};
g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s))
}
/// Is `g` a Perceptual linear or radial gradient of spot inks, alone or mixed with process
/// inks, that paints a fill (`fill`) without dither or a stroke, with a curve short enough for
/// [`vectorcraft_color::Gradient::fits_ink_tints`]: one that [`Self::perceptual_spot_stops`]
/// writes as a Separation shading, or [`Self::device_n_stops`] as a DeviceN shading, rather
/// than an image?
pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool {
let gr = &g.gradient;
!gr.method.is_classic() && gr.kind != GradientKind::Freeform && !(fill && gr.dither) && self.one_spot_ink(gr).is_some() && gr.fits_ink_tints()
!gr.method.is_classic()
&& gr.kind != GradientKind::Freeform
&& !(fill && gr.dither)
&& (self.one_spot_ink(gr).is_some() || self.device_n_inks_only(gr))
&& gr.fits_ink_tints()
}
/// The stops of a Perceptual gradient of tints of one spot ink (or paper white, 0% of it) as
@@ -376,7 +407,9 @@ impl Exporter<'_> {
let doc = self.doc;
let from_stops = g.gradient.kind != GradientKind::Freeform || g.freeform.is_none();
if from_stops && g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) {
self.warn("spot colors in gradients exported as images (freeform, dithered or Perceptual gradients that mix inks) are written in process colors");
self.warn(
"spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
);
}
}
+1 -1
View File
@@ -1278,7 +1278,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi
`pdf::tests::dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill` and the Gradient panel's
`the_method_menu_makes_the_gradient_perceptual`. Scores and hours unchanged.
PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html): "Gradient colors can be assigned as CMYK process colors, RGB colors, or a Spot color."; [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html): "a separate plate is created for each spot color"). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with other colors are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings` and `spot_gradients_drawn_as_images_warn`.
PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html): "Gradient colors can be assigned as CMYK process colors, RGB colors, or a Spot color."; [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html): "a separate plate is created for each spot color"). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with RGB or Lab stops are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve` and `ink_mixes_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`.
## Artwork Cleanup add-on (2026-10-30)