mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
PDF export: a Perceptual gradient of one spot ink's tints keeps its ink
A Perceptual linear or radial gradient was always written as an image, so a gradient of spot ink tints lost its plate, and no warning named the spot ink. PDF export now writes a Perceptual gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink. Its tints follow the gradient's curve: at each stop the renderer places on the curve, the tint with the OKLab lightness of the color there. Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with other colors are still images, and the export now warns when such a gradient has stops linked to a spot swatch that its spot colors are written in process colors.
This commit is contained in:
@@ -78,6 +78,14 @@ 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;
|
||||
/// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within
|
||||
/// 1/65536 of a tint, finer than the 1/255 steps PDF writes.
|
||||
const TINT_STEPS: u32 = 16;
|
||||
/// The least OKLab lightness by which an ink at 100% must differ from paper white for
|
||||
/// [`Gradient::ink_tints`] to tell its tints apart by lightness.
|
||||
const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0;
|
||||
/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for.
|
||||
const MAX_INK_TINTS: usize = 4096;
|
||||
|
||||
/// 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
|
||||
@@ -284,6 +292,43 @@ impl Gradient {
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
|
||||
})
|
||||
}
|
||||
/// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as
|
||||
/// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab
|
||||
/// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings. Mixed
|
||||
/// linearly between them, the tints follow a Perceptual gradient's curve. `None` when the
|
||||
/// ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of paper white's (a white or nearly
|
||||
/// white ink), so lightness does not tell its tints apart, and when the curve has more than
|
||||
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
|
||||
pub fn ink_tints(&self, ink: &Color) -> Option<Vec<(f32, f32, f32)>> {
|
||||
use crate::libraries::srgb_to_oklab;
|
||||
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
|
||||
if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN {
|
||||
return None;
|
||||
}
|
||||
let curve: Vec<_> = self.expanded().take(MAX_INK_TINTS + 1).collect();
|
||||
if curve.len() > MAX_INK_TINTS {
|
||||
return None;
|
||||
}
|
||||
let tint = |c: &Color| {
|
||||
let want = lightness(c);
|
||||
let (mut lo, mut hi) = (0.0f32, 1.0f32);
|
||||
for _ in 0..TINT_STEPS {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if lightness(&ink.tinted(mid)) > want {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
(lo + hi) / 2.0
|
||||
};
|
||||
Some(curve.into_iter().map(|(t, c, o, _)| (t, tint(&c), o)).collect())
|
||||
}
|
||||
/// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so
|
||||
/// [`Self::ink_tints`] finds tints for it?
|
||||
pub fn fits_ink_tints(&self) -> bool {
|
||||
self.expanded().nth(MAX_INK_TINTS).is_none()
|
||||
}
|
||||
/// 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) {
|
||||
|
||||
@@ -455,3 +455,34 @@ fn perceptual_gradients_draw_as_pixels() {
|
||||
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on
|
||||
/// the curve has the lightness of the color mixed there, so the tints between the stops are not
|
||||
/// a straight line.
|
||||
#[test]
|
||||
fn ink_tints_follow_the_perceptual_curve() {
|
||||
use crate::libraries::srgb_to_oklab;
|
||||
let ink = Color::cmyk(0.0, 1.0, 0.0, 0.0);
|
||||
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]);
|
||||
g.method = GradientMethod::Perceptual;
|
||||
assert!(g.fits_ink_tints());
|
||||
let tints = g.ink_tints(&ink).expect("a magenta ink's tints differ in lightness");
|
||||
assert!(tints.len() > 2, "the curve's stops: {tints:?}");
|
||||
let (first, last) = (tints[0], tints[tints.len() - 1]);
|
||||
assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}");
|
||||
assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}");
|
||||
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
|
||||
for (t, tint, _) in &tints {
|
||||
let want = lightness(&g.sample(*t).0);
|
||||
assert!((lightness(&ink.tinted(*tint)) - want).abs() < 1e-3, "at {t}: tint {tint}");
|
||||
}
|
||||
let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint");
|
||||
assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}");
|
||||
// A white ink's tints all have paper white's lightness: no tints by lightness.
|
||||
assert_eq!(g.ink_tints(&Color::WHITE), None);
|
||||
// More stops on the curve than MAX_INK_TINTS: no tints.
|
||||
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 { ink } else { Color::WHITE })).collect();
|
||||
assert_eq!(g.ink_tints(&ink), None);
|
||||
assert!(!g.fits_ink_tints());
|
||||
}
|
||||
|
||||
@@ -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; 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 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",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
|
||||
@@ -192,3 +192,78 @@ fn linked_stops_round_trip() {
|
||||
assert_eq!(old, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!(!serde_json::to_string(&old).unwrap().contains("tint"));
|
||||
}
|
||||
|
||||
/// `stops` with `extra` (method, dither…) as a gradient edit.
|
||||
fn with(mut stops: Value, extra: Value) -> Value {
|
||||
if let (Some(g), Some(e)) = (stops.as_object_mut(), extra.as_object()) {
|
||||
g.extend(e.clone());
|
||||
}
|
||||
stops
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perceptual_spot_gradients_export_as_separation_shadings() {
|
||||
let (mut s, _) = setup();
|
||||
let ink_to_tint = json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Ink", "tint": 20}]});
|
||||
for (what, stops) in [("Ink → paper", ink_to_white(80.0)), ("Ink → Ink 20 %", ink_to_tint)] {
|
||||
run(&mut s, "paint.editGradient", with(stops, json!({"method": "perceptual"})));
|
||||
let (pdf, warnings) = pdf_of(&s);
|
||||
assert!(!pdf.contains("/Subtype/Image"), "{what}: no image");
|
||||
assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "{what}: a Separation shading");
|
||||
// The curve's stops: a stitching function of more than the two ends.
|
||||
assert!(pdf.contains("/FunctionType 3"), "{what}: tints on the curve");
|
||||
assert!(!warnings.iter().any(|w| w.contains("spot")), "{what}: {warnings:?}");
|
||||
}
|
||||
// A radial one, and one on a stroke, too.
|
||||
run(&mut s, "paint.editGradient", with(ink_to_white(100.0), json!({"method": "perceptual", "kind": "radial"})));
|
||||
let (pdf, _) = pdf_of(&s);
|
||||
assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/Separation/Ink/DeviceCMYK"), "radial");
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ffffff"}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#000000"}));
|
||||
run(&mut s, "stroke.set", json!({"weight": 6}));
|
||||
run(&mut s, "paint.editGradient", with(ink_to_white(100.0), json!({"method": "perceptual", "stroke": true})));
|
||||
let (pdf, _) = pdf_of(&s);
|
||||
assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/Separation/Ink/DeviceCMYK"), "stroke");
|
||||
// A white spot ink has paper white's lightness at every tint: the stops keep their own tints.
|
||||
run(&mut s, "swatch.new", json!({"name": "Opaque", "color": "#ffffff", "spot": true}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
let white =
|
||||
json!({"stops": [{"offset": 0, "swatch": "Opaque"}, {"offset": 1, "swatch": "Opaque", "tint": 40}], "method": "perceptual", "stroke": false});
|
||||
run(&mut s, "paint.editGradient", white);
|
||||
let (pdf, _) = pdf_of(&s);
|
||||
let at = pdf.find("/FunctionType").unwrap_or(0);
|
||||
assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/Separation/Opaque"), "white ink: {}", &pdf[at..(at + 200).min(pdf.len())]);
|
||||
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 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}}]});
|
||||
// More stops than the export finds tints for: an image, as before.
|
||||
let n = 4097;
|
||||
let stops: Vec<Value> = (0..n)
|
||||
.map(|i| {
|
||||
let offset = i as f64 / (n - 1) as f64;
|
||||
if i % 2 == 0 { json!({"offset": offset, "swatch": "Ink"}) } else { json!({"offset": offset, "color": "#ffffff"}) }
|
||||
})
|
||||
.collect();
|
||||
for (what, edit) in [
|
||||
("Perceptual Ink → cyan", with(ink_to_cyan, 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"})),
|
||||
] {
|
||||
run(&mut s, "paint.editGradient", edit);
|
||||
let (pdf, warnings) = pdf_of(&s);
|
||||
assert!(pdf.contains("/Subtype/Image"), "{what}: an image");
|
||||
assert!(spot_warning(&warnings), "{what}: {warnings:?}");
|
||||
}
|
||||
// A Perceptual gradient without spot inks is an image, without the warning.
|
||||
let blue_yellow = json!({"stops": [{"offset": 0, "color": "#0000ff"}, {"offset": 1, "color": "#ffff00"}]});
|
||||
run(&mut s, "paint.editGradient", with(blue_yellow, json!({"method": "perceptual", "dither": false})));
|
||||
let (pdf, warnings) = pdf_of(&s);
|
||||
assert!(pdf.contains("/Subtype/Image") && !pdf.contains("/Separation"), "a process Perceptual gradient");
|
||||
assert!(!spot_warning(&warnings), "{warnings:?}");
|
||||
}
|
||||
|
||||
@@ -348,11 +348,12 @@ 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`])?
|
||||
/// ([`vectorcraft_color::GradientPaint::draws_as_pixels`]), but not a Perceptual one of tints
|
||||
/// of one spot ink ([`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(),
|
||||
Paint::Gradient(g) => g.draws_as_pixels(fill),
|
||||
Paint::Gradient(g) => g.draws_as_pixels(fill) && !self.perceptual_spot(g, fill),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,10 @@ impl Exporter<'_> {
|
||||
Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()),
|
||||
Paint::Gradient(g) => {
|
||||
let geom = g.resolve(bounds);
|
||||
let spot = (g.gradient.kind != GradientKind::Freeform).then(|| self.spot_stops(&g.gradient)).flatten();
|
||||
let spot = match g.gradient.kind {
|
||||
GradientKind::Freeform => None,
|
||||
_ => self.perceptual_spot_stops(&g.gradient).or_else(|| self.spot_stops(&g.gradient)),
|
||||
};
|
||||
let colored = match spot {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
@@ -333,6 +336,50 @@ impl Exporter<'_> {
|
||||
Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into())
|
||||
}
|
||||
|
||||
/// The spot swatch whose tints are every stop of `g` that isn't paper white, unlinked; `None`
|
||||
/// when no stop is linked to a spot swatch or a stop has another color.
|
||||
fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> {
|
||||
let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot));
|
||||
let ink = g.stops.iter().find_map(spot)?;
|
||||
let paper = |s: &vectorcraft_color::GradientStop| s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3];
|
||||
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?
|
||||
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()
|
||||
}
|
||||
|
||||
/// The stops of a Perceptual gradient of tints of one spot ink (or paper white, 0% of it) as
|
||||
/// tints of that ink, a Separation shading: the stops on its curve with the tints whose
|
||||
/// lightness is the curve's there ([`vectorcraft_color::Gradient::ink_tints`]). `None` for
|
||||
/// other gradients, and for an ink too light for lightness to tell its tints apart, which
|
||||
/// [`Self::spot_stops`] then writes with the stops' own tints.
|
||||
fn perceptual_spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> {
|
||||
if g.method.is_classic() {
|
||||
return None;
|
||||
}
|
||||
let ink = self.one_spot_ink(g)?;
|
||||
let doc = self.doc;
|
||||
let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true);
|
||||
let space = self.separation(ink)?;
|
||||
Some(g.ink_tints(&full)?.into_iter().map(|(o, t, a)| (o, SepColor::new(q(t), space.clone()).into(), a)).collect())
|
||||
}
|
||||
|
||||
/// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is
|
||||
/// in process colors, so the spot inks have no plate of their own.
|
||||
fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -349,6 +396,7 @@ impl Exporter<'_> {
|
||||
if !(long > 1e-9 && long.is_finite()) {
|
||||
return;
|
||||
}
|
||||
self.warn_spot_image(g);
|
||||
let freeform = g.gradient.kind == GradientKind::Freeform;
|
||||
let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX };
|
||||
let k = (self.doc.raster_effects_ppi / 72.0).min(max / long);
|
||||
|
||||
@@ -1265,6 +1265,8 @@ 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`.
|
||||
|
||||
## Artwork Cleanup add-on (2026-10-30)
|
||||
|
||||
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Artwork Cleanup and its
|
||||
|
||||
Reference in New Issue
Block a user