M3.59: tints of global and spot colours, tint swatches and Global colour balance

A colour linked to a global or spot swatch keeps a tint (Paint::Solid.tint, 1 by default and
left out of files at 100 %): paint.setFill/setStroke {swatch, tint} apply the swatch at that
percentage, swatch edits recolour every linked colour at its own tint, and Color::tinted
resolves it (CMYK and Gray inks scale, RGB mixes toward white). swatch.new saves a tint as a
tint swatch ("Ink 40%") linked to its base. Spot tints separate as that percentage of their
plate (Separations Preview, PDF Separation value) instead of an ink-ratio guess. Adjust Color
Balance's Global mode (tint) shifts the tints of linked colours. The Color panel shows Tint
mode (the swatch chip, its name and a T slider) for linked colours; the Swatches panel
highlights the tint swatch in use. Gradient stops get the same link and tint fields (the
swatch walks cover them), so GradientStop is no longer Copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 02:28:47 +07:00
parent e94748dc17
commit b2ececc7b4
42 changed files with 918 additions and 277 deletions

View File

@ -37,9 +37,8 @@ fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
}
fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
if let Paint::Solid { color, swatch } = p {
*color = map_color(color, mode, key);
*swatch = None;
if let Paint::Solid { color, .. } = p {
*p = Paint::solid(map_color(color, mode, key));
}
}
@ -83,9 +82,8 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Solid { color: c, swatch } = p {
*c = c.lerp(color, amount);
*swatch = None;
if let Paint::Solid { color: c, .. } = p {
*p = Paint::solid(c.lerp(color, amount));
}
}
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {

View File

@ -144,7 +144,7 @@ impl Freeform {
/// swatch chips, exports without freeform shading and re-seeding on other art use.
pub fn stops(&self) -> Vec<GradientStop> {
let n = self.points.len();
let stop = |offset: f32, p: &FreeformPoint| GradientStop { offset, color: p.color, opacity: p.opacity, midpoint: 0.5 };
let stop = |offset: f32, p: &FreeformPoint| GradientStop { opacity: p.opacity, ..GradientStop::new(offset, p.color) };
match n {
0 => Gradient::default().stops,
1 => vec![stop(0.0, &self.points[0]), stop(1.0, &self.points[0])],

View File

@ -31,7 +31,7 @@ impl GradientKind {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
pub offset: f32,
@ -41,6 +41,27 @@ pub struct GradientStop {
/// Midpoint to the next stop, 0.13..=0.87 (Illustrator's diamond), default 0.5.
#[serde(default = "half")]
pub midpoint: f32,
/// The global or spot swatch this stop's colour is linked to (edits to the swatch recolour it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swatch: Option<String>,
/// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint.
#[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")]
pub tint: f32,
}
impl GradientStop {
/// An opaque, unlinked stop of `color` at `offset` with a centred midpoint.
pub fn new(offset: f32, color: Color) -> Self {
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 }
}
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked.
pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) {
self.color = color;
(self.swatch, self.tint) = match link {
Some((n, t)) => (Some(n), t),
None => (None, 1.0),
};
}
}
fn one() -> f32 {
@ -59,13 +80,7 @@ pub struct Gradient {
impl Default for Gradient {
/// Illustrator's default "White, Black" gradient.
fn default() -> Self {
Self {
kind: GradientKind::Linear,
stops: vec![
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
],
}
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
}
}
@ -137,7 +152,7 @@ pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
let offset = offset.clamp(0.0, 1.0);
let (color, opacity) = g.sample(offset);
let mut stops = g.stops.clone();
let i = place_stop(&mut stops, GradientStop { offset, color, opacity, midpoint: 0.5 });
let i = place_stop(&mut stops, GradientStop { opacity, ..GradientStop::new(offset, color) });
(stops, i)
}
@ -166,19 +181,22 @@ pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<Gradient
/// Add a copy of stop `i` at `offset` (Alt-drag). Returns the stops and the copy's index.
pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
let mut v = stops.to_vec();
let Some(&s) = v.get(i) else { return (v, i) };
let Some(s) = v.get(i).cloned() else { return (v, i) };
let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s });
(v, ni)
}
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another); offsets, opacities
/// and midpoints stay. Out-of-range indices leave the stops unchanged.
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another), with their swatch
/// links and tints; offsets, opacities and midpoints stay. Out-of-range indices leave the stops
/// unchanged.
pub fn swap_stop_colors(stops: &[GradientStop], a: usize, b: usize) -> Vec<GradientStop> {
let mut v = stops.to_vec();
if a < v.len() && b < v.len() {
let c = v[a].color;
v[a].color = v[b].color;
v[b].color = c;
if a < v.len() && b < v.len() && a != b {
let (lo, hi) = v.split_at_mut(a.max(b));
let (x, y) = (&mut lo[a.min(b)], &mut hi[0]);
std::mem::swap(&mut x.color, &mut y.color);
std::mem::swap(&mut x.swatch, &mut y.swatch);
std::mem::swap(&mut x.tint, &mut y.tint);
}
v
}
@ -517,7 +535,7 @@ mod tests {
fn g3() -> Gradient {
let mut g = Gradient::default();
g.stops.insert(1, GradientStop { offset: 0.5, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 });
g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
g
}

View File

@ -240,6 +240,22 @@ impl Color {
}
}
}
/// Tint `t` (0..1) of this colour, the colour a tint of a global or spot swatch shows: CMYK and
/// Gray inks scale by `t`, RGB mixes toward white (`t` = 0 is paper white, 1 the colour itself).
pub fn tinted(self, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
if t == 1.0 {
return self;
}
match self {
Color::Cmyk { c, m, y, k } => Color::Cmyk { c: c * t, m: m * t, y: y * t, k: k * t },
Color::Gray { k } => Color::Gray { k: k * t },
Color::Rgb { r, g, b } => {
let w = |v: f32| v + (1.0 - v) * (1.0 - t);
Color::Rgb { r: w(r), g: w(g), b: w(b) }
}
}
}
pub fn model_name(&self) -> &'static str {
match self {
Color::Rgb { .. } => "RGB",
@ -262,10 +278,15 @@ pub enum Paint {
#[default]
None,
Solid {
/// The colour shown: with a link, the swatch's colour at `tint` ([`Color::tinted`]).
color: Color,
/// Name of the global swatch this colour is linked to (edits to the swatch update it).
#[serde(default, skip_serializing_if = "Option::is_none")]
swatch: Option<String>,
/// Tint of the linked swatch (0..1; 1 without a link): a spot tint prints this much of its
/// plate, and swatch edits keep it.
#[serde(default = "full_tint", skip_serializing_if = "is_full_tint")]
tint: f32,
},
Gradient(Box<GradientPaint>),
Pattern {
@ -281,9 +302,22 @@ fn is_identity(a: &kurbo::Affine) -> bool {
*a == kurbo::Affine::IDENTITY
}
pub(crate) fn full_tint() -> f32 {
1.0
}
pub(crate) fn is_full_tint(t: &f32) -> bool {
*t == 1.0
}
/// A tint as a whole percentage ("40" for 0.4).
pub fn tint_percent(t: f32) -> f32 {
(t.clamp(0.0, 1.0) * 100.0).round()
}
impl Paint {
pub fn solid(c: Color) -> Self {
Paint::Solid { color: c, swatch: None }
Paint::Solid { color: c, swatch: None, tint: 1.0 }
}
pub fn is_none(&self) -> bool {
matches!(self, Paint::None)
@ -297,12 +331,30 @@ impl Paint {
pub fn label(&self) -> String {
match self {
Paint::None => "None".into(),
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
Paint::Solid { color, swatch: Some(n), tint } if *tint < 1.0 => format!("{n} {}% ({})", tint_percent(*tint), color.to_hex()),
Paint::Solid { color, swatch: Some(n), .. } => format!("{n} ({})", color.to_hex()),
Paint::Solid { color, .. } => color.to_hex(),
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
Paint::Pattern { pattern, .. } => format!("pattern {pattern}"),
}
}
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
/// left without a link gets tint 1. Returns whether anything changed.
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let changed = f(c, link, tint);
if link.is_none() {
*tint = 1.0;
}
changed
};
match self {
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch),
_ => false,
}
}
}
#[cfg(test)]
@ -376,6 +428,53 @@ mod tests {
assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0));
}
#[test]
fn tints_scale_inks_and_mix_rgb_toward_white() {
let close = |a: Color, b: Color| {
let v = |c: Color| match c {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Gray { k } => vec![k],
};
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-6)
};
assert!(close(Color::cmyk(0.5, 1.0, 0.0, 0.2).tinted(0.4), Color::cmyk(0.2, 0.4, 0.0, 0.08)));
assert!(close(Color::gray(0.5).tinted(0.5), Color::gray(0.25)));
assert!(close(Color::rgb(1.0, 0.0, 0.5).tinted(0.4), Color::rgb(1.0, 0.6, 0.8)), "RGB mixes toward white");
assert_eq!(Color::rgb(0.2, 0.4, 0.6).tinted(1.0), Color::rgb(0.2, 0.4, 0.6));
assert_eq!(Color::cmyk(0.3, 0.3, 0.3, 0.3).tinted(0.0), Color::cmyk(0.0, 0.0, 0.0, 0.0));
assert_eq!(Color::rgb(0.0, 0.0, 0.0).tinted(-1.0).to_hex(), "#ffffff", "clamped");
assert_eq!(tint_percent(0.404), 40.0);
}
#[test]
fn tints_serialize_only_below_full_and_read_old_files() {
let full = serde_json::to_string(&Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 }).unwrap();
assert!(!full.contains("tint"), "{full}");
let old: Paint = serde_json::from_str(r#"{"type":"solid","color":{"model":"gray","k":1.0},"swatch":"Ink"}"#).unwrap();
assert_eq!(old, Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 });
let t = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"tint\":0.4"), "{s}");
assert_eq!(serde_json::from_str::<Paint>(&s).unwrap(), t);
assert_eq!(t.label(), "Ink 40% (#999999)");
}
#[test]
fn mapping_links_resets_the_tint_of_unlinked_colours() {
let mut p = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
assert!(p.map_links(&mut |_, l, _| l.take().is_some()));
assert_eq!(p, Paint::solid(Color::gray(0.4)));
let mut g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
g.map_links(&mut |_, l, t| {
*l = Some("Ink".into());
*t = 0.5;
true
});
let Paint::Gradient(g) = g else { unreachable!() };
assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5));
}
#[test]
fn paint_serde() {
let p = Paint::solid(Color::rgb8(10, 20, 30));

View File

@ -170,7 +170,7 @@ fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
/// A gradient swatch through `stops` of (offset, colour, opacity).
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { offset, color, opacity, midpoint: 0.5 }).collect();
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false }
}

View File

@ -241,8 +241,8 @@ mod tests {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
kind: GradientKind::Linear,
stops: vec![
GradientStop { offset: 0.0, color: Color::cmyk(0.1, 0.2, 0.3, 0.0), opacity: 1.0, midpoint: 0.4 },
GradientStop { offset: 1.0, color: Color::rgb8(255, 0, 0), opacity: 0.5, midpoint: 0.5 },
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
],
})));
SwatchLibrary {

View File

@ -17,6 +17,16 @@ pub struct Swatch {
pub spot: bool,
}
impl Swatch {
/// A tint swatch's base swatch and tint: its colour links to another (global) swatch.
pub fn tint_of(&self) -> Option<(&str, f32)> {
match &self.paint {
Paint::Solid { swatch: Some(base), tint, .. } => Some((base, *tint)),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SwatchGroup {
pub name: String,
@ -101,10 +111,7 @@ fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
name: name.into(),
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
kind,
stops: vec![
GradientStop { offset: 0.0, color: Color::from_hex(a).unwrap(), opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::from_hex(b).unwrap(), opacity: 1.0, midpoint: 0.5 },
],
stops: vec![GradientStop::new(0.0, Color::from_hex(a).unwrap()), GradientStop::new(1.0, Color::from_hex(b).unwrap())],
}))),
global: false,
spot: false,

View File

@ -48,7 +48,7 @@ impl OverprintBlack {
/// [`Self::spot`]), or a gradient whose every stop is.
fn black(&self, p: &Paint, spots: &[String]) -> bool {
match p {
Paint::Solid { color, swatch } => {
Paint::Solid { color, swatch, .. } => {
(self.spot || !swatch.as_ref().is_some_and(|s| spots.contains(s))) && is_black_ink(color, self.min_k, self.rich)
}
Paint::Gradient(g) => !g.gradient.stops.is_empty() && g.gradient.stops.iter().all(|s| is_black_ink(&s.color, self.min_k, self.rich)),
@ -159,7 +159,7 @@ mod tests {
assert!(!n.appearance.fill().unwrap().overprint, "a gradient with a white stop is not black");
// Spot-linked blacks only with `spot`; type runs too.
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()) };
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()), tint: 1.0 };
let style = CharStyle { fill: spot, stroke: k, stroke_width: 1.0, ..CharStyle::default() };
let mut t = Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Hi", style))));
OverprintBlack::default().apply(&mut t, &["Ink".to_string()]);

View File

@ -1,13 +1,15 @@
//! Swatch lookups across colour groups and the walks over the paints that link to swatches.
//!
//! A swatch lives either in `Document::swatches` or in one of `Document::swatch_groups`; every
//! lookup here covers both. Solid paints link to a global swatch by name (`Paint::Solid.swatch`);
//! [`Document::map_solid_paints`] rewrites those links (and colours) wherever art can hold them,
//! copying only the nodes that change.
//! lookup here covers both. Solid paints and gradient stops link to a global swatch by name, at a
//! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a
//! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
//! change.
use std::sync::Arc;
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_color::{Color, Paint, Swatch, tint_percent};
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind};
@ -25,6 +27,29 @@ impl Document {
pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> {
self.swatches_iter_mut().find(|s| s.name == name)
}
/// The colour of global (or spot) swatch `name`, which its tints scale; `None` when `name`
/// isn't a global solid colour of its own (a tint swatch isn't).
pub fn global_color(&self, name: &str) -> Option<Color> {
match &self.swatch(name).filter(|w| w.global)?.paint {
Paint::Solid { color, swatch: None, .. } => Some(*color),
_ => None,
}
}
/// The link a colour taken from swatch `name` gets: a global solid colour links to itself at
/// 100 %, a tint swatch to its base at its tint; `None` for other swatches.
pub fn swatch_link(&self, name: &str) -> Option<(String, f32)> {
let w = self.swatch(name)?;
match (w.tint_of(), &w.paint) {
(Some((base, tint)), _) => Some((base.to_string(), tint)),
(None, Paint::Solid { .. }) if w.global => Some((name.to_string(), 1.0)),
_ => None,
}
}
/// Tint `tint` (0..1) of global swatch `name` as a solid paint linked to it.
pub fn tint_paint(&self, name: &str, tint: f32) -> Option<Paint> {
let tint = tint.clamp(0.0, 1.0);
Some(Paint::Solid { color: self.global_color(name)?.tinted(tint), swatch: Some(name.to_string()), tint })
}
/// Index of the colour group holding swatch `name` (`None` when ungrouped or missing).
pub fn swatch_group_of(&self, name: &str) -> Option<usize> {
self.swatch_groups.iter().position(|g| g.swatches.iter().any(|s| s.name == name))
@ -49,6 +74,7 @@ impl Document {
/// "New Pattern Swatch 1"…
pub fn new_swatch_name(&self, paint: &Paint) -> String {
let base = match paint {
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.free_swatch_name(&format!("{n} {}%", tint_percent(*tint))),
Paint::Solid { color, .. } => return self.free_swatch_name(&color_name(*color)),
Paint::Gradient(_) => "New Gradient Swatch",
_ => "New Pattern Swatch",
@ -66,11 +92,12 @@ impl Document {
Some(list.remove(i))
}
/// Visit every solid paint (fills, strokes and text runs) in the art, symbol definitions,
/// pattern tiles and graphic styles. `f(colour, link)` may change both and returns true when it
/// did. Only nodes holding a changed paint (and the paths to them) are copied. Returns the
/// number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
/// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the
/// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may
/// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed
/// paint (and the paths to them) are copied. Returns the number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
let mut n = map_trees(&mut self.layers, f);
for s in &mut self.symbols {
n += map_tree(&mut s.art, f);
@ -80,15 +107,18 @@ impl Document {
}
for gs in &mut self.graphic_styles {
for it in &mut gs.appearance.items {
n += usize::from(map_solid(item_paint_mut(it), f));
n += usize::from(item_paint_mut(it).map_links(f));
}
}
for w in self.swatches_iter_mut() {
n += usize::from(w.paint.map_links(f));
}
n
}
/// [`Document::map_solid_paints`] limited to the subtrees of `ids` (each paint visited once even
/// when an id is inside another).
pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
/// [`Document::map_solid_paints`] limited to the art in the subtrees of `ids` (each paint
/// visited once even when an id is inside another).
pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut LinkFn) -> usize {
let roots: Vec<NodeId> =
ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect();
let mut n = 0;
@ -135,14 +165,18 @@ pub fn color_name(c: Color) -> String {
}
}
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text
/// runs (with their link), then gradient stops and mesh points (unlinked), in paint order.
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
/// true when it changed them.
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs
/// and gradient stops (with their link), then mesh points (unlinked), in paint order.
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
n.walk(&mut |m| {
for p in node_paints(m) {
match p {
Paint::Solid { color, swatch } => f(color, swatch.as_deref()),
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, None)),
Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()),
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())),
_ => {}
}
}
@ -184,24 +218,17 @@ fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
}
/// Apply `f` to a solid paint; true when it changed.
fn map_solid(p: &mut Paint, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> bool {
match p {
Paint::Solid { color, swatch } => f(color, swatch),
_ => false,
}
}
/// `n` with `f` applied to its subtree's solid paints, or `None` when nothing changed (then nothing
/// was copied). Also returns the number of changed paints.
fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> Option<(Node, usize)> {
// Try each own paint on a scratch copy first, so unchanged nodes are never cloned.
let mut changes: Vec<(usize, Color, Option<String>)> = vec![];
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
/// nothing was copied). Also returns the number of changed paints.
fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
// Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never
// cloned.
let mut changes: Vec<(usize, Paint)> = vec![];
for (i, p) in node_paints(n).enumerate() {
if let Paint::Solid { color, swatch } = p {
let (mut c, mut s) = (*color, swatch.clone());
if f(&mut c, &mut s) {
changes.push((i, c, s));
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) {
let mut q = p.clone();
if q.map_links(f) {
changes.push((i, q));
}
}
}
@ -212,10 +239,9 @@ fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool
let mut changes = changes.into_iter().peekable();
for (i, p) in node_paints_mut(&mut m).enumerate() {
if changes.peek().is_some_and(|c| c.0 == i)
&& let (Some((_, c, s)), Paint::Solid { color, swatch }) = (changes.next(), p)
&& let Some((_, q)) = changes.next()
{
*color = c;
*swatch = s;
*p = q;
}
}
out = Some(m);
@ -234,7 +260,7 @@ fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool
out.map(|m| (m, count))
}
fn map_tree(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
*n = Arc::new(new);
@ -244,7 +270,7 @@ fn map_tree(n: &mut Arc<Node>, f: &mut dyn FnMut(&mut Color, &mut Option<String>
}
}
fn map_trees(v: &mut [Arc<Node>], f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) -> usize {
fn map_trees(v: &mut [Arc<Node>], f: &mut LinkFn) -> usize {
v.iter_mut().map(|n| map_tree(n, f)).sum()
}
@ -257,7 +283,7 @@ mod tests {
use crate::Appearance;
fn linked(color: Color, name: &str) -> Paint {
Paint::Solid { color, swatch: Some(name.into()) }
Paint::Solid { color, swatch: Some(name.into()), tint: 1.0 }
}
/// A document with a grouped global swatch "Brand", a rectangle filled with it and an unlinked one.
@ -309,7 +335,7 @@ mod tests {
let (mut d, a, b) = doc();
let before = d.clone();
let blue = Color::rgb(0.0, 0.0, 1.0);
let n = d.map_solid_paints(&mut |c, s| {
let n = d.map_solid_paints(&mut |c, s, _| {
if s.as_deref() != Some("Brand") {
return false;
}
@ -323,7 +349,7 @@ mod tests {
assert!(Arc::ptr_eq(&unchanged(&d), &unchanged(&before)), "the unlinked rectangle is shared, not copied");
// Nothing to change: the tree is untouched.
let again = d.clone();
assert_eq!(d.map_solid_paints(&mut |_, _| false), 0);
assert_eq!(d.map_solid_paints(&mut |_, _, _| false), 0);
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
}
@ -332,7 +358,7 @@ mod tests {
let (mut d, a, b) = doc();
let layer = d.layers[0].id;
let mut seen = 0;
let n = d.map_solid_paints_in(&[layer, a], &mut |_, s| {
let n = d.map_solid_paints_in(&[layer, a], &mut |_, s, _| {
seen += 1;
s.take().is_some()
});

View File

@ -2,7 +2,7 @@
//! three Blend commands. They recolour the selected objects and everything inside them through the
//! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text
//! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are
//! unlinked when they change.
//! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adjust Color Balance…",
["Edit", "Edit Colors"],
None,
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel, default 0), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}. Global mode (tints of global and spot colours) isn't available yet",
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}",
has_selection,
adjust_balance
),
@ -131,34 +131,37 @@ pub(crate) fn to_gray(c: Color) -> Color {
c.in_model(Model::Gray)
}
/// Apply `f` to a paint; returns whether anything changed.
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
match p {
Paint::Solid { color, swatch } => {
let n = f(*color);
if n != *color {
*color = n;
*swatch = None;
return true;
}
false
}
Paint::Gradient(g) => {
let mut ch = false;
for st in &mut g.gradient.stops {
let n = f(st.color);
if n != st.color {
st.color = n;
ch = true;
}
}
if ch {
g.swatch = None;
}
ch
}
_ => false,
/// What a recolouring pass applies to a colour that can link to a swatch (a solid colour or a
/// gradient stop): `(colour, link, tint)`, true when it changed them.
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient
/// that changes is no longer its gradient swatch's. Returns whether anything changed.
fn map_links(p: &mut Paint, f: &LinkMap) -> bool {
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
if changed && let Paint::Gradient(g) = p {
g.swatch = None;
}
changed
}
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
/// anything changed.
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
map_links(p, &|c, link, _| {
let n = f(*c);
if n == *c {
return false;
}
*c = n;
*link = None;
true
})
}
/// The identity colour map (a pass that only maps linked colours).
fn same(c: Color) -> Color {
c
}
/// What a recolouring pass covers: fills (with the points of gradient meshes), strokes, the pixels
@ -191,6 +194,8 @@ impl Scope {
/// change are kept as copies.
pub(crate) struct Recolor<'a> {
f: &'a dyn Fn(Color) -> Color,
/// Maps solid colours and gradient stops with their link and tint in place of `f`.
links: Option<&'a LinkMap<'a>>,
scope: Scope,
/// Image key → the key of its recoloured copy (`None`: unchanged).
images: HashMap<String, Option<String>>,
@ -203,7 +208,13 @@ pub(crate) struct Recolor<'a> {
impl<'a> Recolor<'a> {
pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self {
Self { f, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
Self { f, links: None, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
}
/// A pass that maps only solid colours and gradient stops, with their swatch link and tint
/// (`links`); meshes and images keep their colours.
pub(crate) fn links(scope: Scope, links: &'a LinkMap<'a>) -> Self {
Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) }
}
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
@ -294,7 +305,10 @@ impl<'a> Recolor<'a> {
fn paint(&mut self, d: &Document, p: &mut Paint) {
let changed = match p {
Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(),
_ => map_paint(p, self.f),
_ => match self.links {
Some(f) => map_links(p, f),
None => map_paint(p, self.f),
},
};
self.changed += changed as usize;
}
@ -348,12 +362,16 @@ impl<'a> Recolor<'a> {
}
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
}
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step.
fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Value> {
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let scope = Scope::of(p);
let n = s.edit(label, |d, _| Ok(Recolor::new(scope, f).run(d, &ids)))?;
let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?;
Ok(json!({ "changed": n }))
}
@ -376,7 +394,8 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
None if given(&["c", "m", "y", "k"]) => "cmyk".into(),
None if given(&["gray"]) => "gray".into(),
None if given(&["r", "g", "b"]) => "rgb".into(),
None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k or gray adjustments")),
None if given(&["tint"]) => "global".into(),
None => return Err(bad(C, "give a mode or r/g/b, c/m/y/k, gray or tint adjustments")),
};
let g = |k: &str| (f64_or(p, k, 0.0).clamp(-100.0, 100.0) / 100.0) as f32;
let cl = |v: f32| v.clamp(0.0, 1.0);
@ -403,7 +422,20 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
})
}
"global" => {
return Err(bad(C, "mode `global` shifts the tints of global and spot colours, which aren't supported yet: use rgb, cmyk or gray"));
// Each linked colour moves to the shifted tint of its global swatch's current colour.
let d = &s.doc()?.doc;
let bases: HashMap<String, Color> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
let dt = g("tint");
let shift = move |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let Some(base) = link.as_ref().and_then(|l| bases.get(l)) else { return false };
let t = cl(*tint + dt);
if t == *tint {
return false;
}
(*tint, *c) = (t, base.tinted(t));
true
};
return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift));
}
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
};

View File

@ -268,6 +268,15 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
_ => {}
}
}
// Tints are their converted swatch's colour at their tint (exact ink percentages).
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
Some((_, base)) if *tint < 1.0 => {
*c = base.tinted(*tint);
true
}
_ => false,
});
Ok(())
})?;
Ok(json!({ "changed": changed }))

View File

@ -218,7 +218,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let stop = |offset: f32, opacity: f32| GradientStop { offset, color: vectorcraft_color::Color::WHITE, opacity, midpoint: 0.5 };
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops });
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0 });

View File

@ -124,6 +124,8 @@ pub(crate) fn parse_stops(v: &Value) -> Parsed<Vec<GradientStop>> {
color,
opacity: opacity.clamp(0.0, 1.0) as f32,
midpoint: num("midpoint")?.unwrap_or(0.5).clamp(0.13, 0.87) as f32,
swatch: None,
tint: 1.0,
})
})
.collect::<Parsed<Vec<_>>>()?;

View File

@ -2,7 +2,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
use super::appearance::{ItemTarget, appearance_targets, edit_items, item_target};
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a gradient swatch fits each object, keeping its aspect), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87}] (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), 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?, 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)} sets the selection's fill and the default (new art fits a gradient to itself)",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, 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 fits each object, keeping its aspect), 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}] (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), 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?, 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)} sets the selection's fill and the default (new art fits a gradient to itself)",
has_doc,
|s, p| set_paint(s, p, true)
),
@ -304,19 +304,18 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
return Ok(Some(Paint::None));
}
if let Some(name) = str_param(p, "swatch") {
let st = s.doc()?;
let d = &s.doc()?.doc;
// A pattern definition works as its swatch even without a swatch entry.
if st.doc.swatch(name).is_none() && st.doc.pattern(name).is_some() {
if d.swatch(name).is_none() && d.pattern(name).is_some() {
return Ok(Some(vectorcraft_doc::pattern::pattern_paint(name)));
}
let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let mut paint = sw.paint.clone();
if sw.global
&& let Paint::Solid { swatch, .. } = &mut paint
{
*swatch = Some(name.to_string());
}
return Ok(Some(paint));
let sw = d.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let tint = tint_param(p, "paint")?;
return match &sw.paint {
Paint::Solid { .. } => swatch_solid(d, name, tint).map(Some).map_err(|e| bad("paint", e)),
_ if tint.is_some() => Err(bad("paint", "`tint` applies to global and spot colours")),
other => Ok(Some(other.clone())),
};
}
if let Some(g) = p.get("gradient") {
let mut gp = super::gradient::parse_gradient(g).map_err(|e| bad("paint", e))?;
@ -330,8 +329,35 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
Ok(None)
}
/// The `tint` param (a percentage) as 0..1.
pub(crate) fn tint_param(p: &Value, cmd: &str) -> Result<Option<f32>> {
p.get("tint")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().map(|t| (t / 100.0).clamp(0.0, 1.0) as f32).ok_or_else(|| bad(cmd, "`tint` must be a number (%)")))
.transpose()
}
/// The solid paint colour swatch `name` applies at `tint` (0..1): a global or spot colour links to
/// itself (default 100%), a tint swatch to its base (default: its own tint), a process colour is
/// just its colour (it has no tints).
pub(crate) fn swatch_solid(d: &Document, name: &str, tint: Option<f32>) -> std::result::Result<Paint, String> {
let sw = d.swatch(name).ok_or_else(|| format!("no swatch `{name}`"))?;
if sw.paint.color().is_none() {
return Err(format!("`{name}` isn't a colour swatch"));
}
match (d.swatch_link(name), tint) {
// A tint swatch whose base is gone keeps its colour.
(Some((base, own)), _) => Ok(d.tint_paint(&base, tint.unwrap_or(own)).unwrap_or_else(|| sw.paint.clone())),
(None, None) => Ok(sw.paint.clone()),
(None, Some(_)) => Err(format!("`{name}` is a process colour: tints apply to global and spot colours")),
}
}
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
if p.get("tint").is_some() && p.get("swatch").is_none() {
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
}
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
apply_paint(s, p, paint, fill)
}

View File

@ -1,9 +1,9 @@
//! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup};
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::swatches::{color_name, node_colors};
use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors};
use vectorcraft_doc::{Document, NodeId};
use super::paint::paint_from;
@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Swatch",
["Window", "Swatches"],
None,
"{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}",
"{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}",
has_doc,
swatch_new
),
@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Options",
["Window", "Swatches"],
None,
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes and text linked to a global swatch take its new colour and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only → {name, relinked: paints changed}",
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
has_doc,
swatch_edit
),
@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatches",
[],
None,
"{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, gradient?, pattern?}], groups: [{name, swatches: [names]}]}",
"{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, tintOf?: base swatch of a tint swatch, tint?: its %, gradient?, pattern?}], groups: [{name, swatches: [names]}]}",
has_doc,
swatch_list
),
@ -129,7 +129,17 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
}
(None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)],
(None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))),
(None, None) => vec![paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone())],
(None, None) => {
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
// `tint` alone: that tint of the global colour the current fill links to.
match (super::paint::tint_param(p, C)?, &paint) {
(Some(t), Paint::Solid { swatch: Some(l), .. }) if p.get("swatch").is_none() => {
vec![s.doc()?.doc.tint_paint(l, t).ok_or_else(|| bad(C, format!("`{l}` isn't a global or spot colour")))?]
}
(Some(_), _) if p.get("swatch").is_none() => return Err(bad(C, "`tint` needs a global or spot colour")),
_ => vec![paint],
}
}
};
let spot = bool_or(p, "spot", false);
let global = spot || bool_or(p, "global", false);
@ -138,6 +148,8 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
let paints = paints
.into_iter()
.map(|paint| match paint {
// A tint stays linked to its base: a tint swatch.
Paint::Solid { swatch: Some(_), tint, .. } if tint < 1.0 && mode.is_none() && !global => Ok(paint),
// The swatch holds the colour itself, not a link to the swatch it may have come from.
Paint::Solid { color, .. } => Ok(Paint::solid(match mode {
Some(m) => convert_to_mode(color, m, C)?,
@ -157,7 +169,9 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
Some(n) => d.free_swatch_name(n),
None => d.new_swatch_name(&paint),
};
let swatch = Swatch { name: name.clone(), paint, global, spot };
// A tint swatch's kind is its base's.
let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. });
let swatch = Swatch { name: name.clone(), paint, global: global && !tint, spot: spot && !tint };
match group {
Some(g) => {
d.swatch_groups.iter_mut().find(|x| x.name == g).ok_or_else(|| bad(C, format!("no colour group `{g}`")))?.swatches.push(swatch)
@ -204,7 +218,8 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
if !bool_or(p, "unlink", true) {
global.clear();
}
let mut unlink = |_: &mut Color, link: &mut Option<String>| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
let mut unlink =
|_: &mut Color, link: &mut Option<String>, _: &mut f32| link.as_ref().is_some_and(|l| global.contains(l)) && link.take().is_some();
let unlinked = s.edit("Delete Swatch", |d, _| {
d.swatch_groups.retain(|g| !names.contains(&g.name));
for n in &names {
@ -244,7 +259,7 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u
return 0;
}
let live: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
d.map_solid_paints_in(ids, &mut |c, link| {
d.map_solid_paints_in(ids, &mut |c, link, _| {
if link.as_ref().is_some_and(|l| live.contains(l)) {
return false;
}
@ -416,15 +431,15 @@ struct Relink {
from: String,
/// The swatch's name after the edit.
to: String,
/// The new colour of a global swatch (linked paints take it).
/// The new colour of a global swatch (linked paints take it, at their tint).
color: Option<Color>,
/// false: drop the links (the swatch stopped being global); paints keep their colour.
keep: bool,
}
impl Relink {
/// Apply to one paint; true when it changed.
fn apply(&self, c: &mut Color, link: &mut Option<String>) -> bool {
/// Apply to one linked colour; true when it changed.
fn apply(&self, c: &mut Color, link: &mut Option<String>, tint: &mut f32) -> bool {
if link.as_deref() != Some(self.from.as_str()) {
return false;
}
@ -436,28 +451,27 @@ impl Relink {
if renamed {
*link = Some(self.to.clone());
}
let recolored = self.color.is_some_and(|n| n != *c);
if let Some(n) = self.color {
let new = self.color.map(|n| n.tinted(*tint));
let recolored = new.is_some_and(|n| n != *c);
if let Some(n) = new {
*c = n;
}
renamed || recolored
}
fn defaults(&self, s: &mut Session) {
map_default_paints(s, &mut |c, l| self.apply(c, l));
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t));
}
}
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
/// preview (Cancel rolls the document back, not the session).
pub(super) fn map_default_paints(s: &mut Session, f: &mut dyn FnMut(&mut Color, &mut Option<String>) -> bool) {
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
if s.in_interaction() {
return;
}
for p in [&mut s.paint.fill, &mut s.paint.stroke] {
if let Paint::Solid { color, swatch } = p {
f(color, swatch);
}
p.map_links(f);
}
}
@ -474,6 +488,11 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) {
return Err(bad(C, "colour, mode and spot apply to solid-colour swatches only"));
}
if let Some((base, _)) = sw.tint_of()
&& ["color", "mode", "paint", "global", "spot"].iter().any(|k| given(k))
{
return Err(bad(C, format!("`{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name)")));
}
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
let paint = match p.get("paint").filter(|v| !v.is_null()) {
Some(v) => match (paint_from(s, v)?, &sw.paint) {
@ -510,7 +529,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(d.map_solid_paints(&mut |c, l| relink.apply(c, l)))
Ok(d.map_solid_paints(&mut |c, l, t| relink.apply(c, l, t)))
})?;
relink.defaults(s);
Ok(json!({"name": to, "relinked": relinked}))
@ -520,10 +539,14 @@ pub(super) fn swatch_json(sw: &Swatch, group: Option<&str>) -> Value {
let mut v = json!({"name": sw.name, "group": group, "global": sw.global, "spot": sw.spot});
match &sw.paint {
Paint::None => v["kind"] = json!("none"),
Paint::Solid { color, .. } => {
Paint::Solid { color, swatch, tint } => {
v["kind"] = json!("color");
v["color"] = json!(color);
v["hex"] = json!(color.to_hex());
if let Some(base) = swatch {
v["tintOf"] = json!(base);
v["tint"] = json!(tint_percent(*tint));
}
}
Paint::Gradient(g) => {
v["kind"] = json!("gradient");
@ -592,20 +615,28 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result<Value> {
fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let (mut links, mut colors, mut gradients, mut patterns) = (vec![], vec![], vec![], vec![]);
// Links with their tints, so a tint swatch is used where its tint of its base is.
let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]);
let mut color = |c: &Color, link: Option<&String>, tint: f32| match link {
Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)),
_ => colors.push(*c),
};
d.visit_paints(&mut |p| match p {
Paint::Solid { swatch: Some(l), .. } if d.swatch(l).is_some() => links.push(l.clone()),
Paint::Solid { color, .. } => colors.push(*color),
Paint::Solid { color: c, swatch, tint } => color(c, swatch.as_ref(), *tint),
Paint::Gradient(g) => {
links.extend(g.swatch.clone());
colors.extend(g.gradient.stops.iter().map(|s| s.color));
g.gradient.stops.iter().for_each(|s| color(&s.color, s.swatch.as_ref(), s.tint));
gradient_links.extend(g.swatch.clone());
gradients.push(g.gradient.clone());
}
Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()),
Paint::None => {}
});
let used = |w: &Swatch| {
links.contains(&w.name)
gradient_links.contains(&w.name)
|| links.iter().any(|(l, t)| match w.tint_of() {
Some((base, tint)) => l == base && *t == tint,
None => *l == w.name,
})
|| match &w.paint {
Paint::None => true,
Paint::Solid { color, .. } => colors.contains(color),
@ -632,11 +663,12 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
}
let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?;
let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone()));
let mut relink = |c: &mut Color, l: &mut Option<String>| {
// Tints keep their tint (of the kept colour).
let mut relink = |c: &mut Color, l: &mut Option<String>, t: &mut f32| {
if !l.as_ref().is_some_and(|l| merged.contains(l)) {
return false;
}
*c = color;
*c = color.tinted(*t);
*l = link.clone();
true
};

View File

@ -397,7 +397,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
(d.swatches, d.swatch_groups) = (defaults, groups);
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
d.map_solid_paints(&mut |_, l| unlink_dead(&live, l));
d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l));
return Ok(added);
}
for (i, w) in defaults.into_iter().enumerate() {
@ -427,7 +427,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
Ok(added)
})?;
if replace {
map_default_paints(s, &mut |_, l| unlink_dead(&live, l));
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l));
}
Ok(json!({ "added": added }))
}

View File

@ -355,7 +355,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut stops = g.gradient.stops;
let n = stops.len();
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.color = color;
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.set_color(color, None);
("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke }))
}
};

View File

@ -847,4 +847,6 @@ mod tests_textedit;
#[cfg(test)]
mod tests_tileedge;
#[cfg(test)]
mod tests_tints;
#[cfg(test)]
mod tests_xform;

View File

@ -104,7 +104,7 @@ fn swatch_links_survive_mode_conversion() {
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap();
s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap();
let Paint::Solid { color, swatch } = fill_of(&s, a) else { panic!() };
let Paint::Solid { color, swatch, .. } = fill_of(&s, a) else { panic!() };
assert_eq!(swatch.as_deref(), Some("Brand"));
let sw = s.doc().unwrap().doc.swatches.iter().find(|w| w.name == "Brand").unwrap().paint.color().unwrap();
assert!(matches!(color, Color::Cmyk { .. }));

View File

@ -52,21 +52,6 @@ fn adjust_balance_modes() {
assert!(s.execute("edit.colors.adjustBalance", &json!({"mode": "lab"})).is_err());
}
#[test]
fn global_mode_needs_tints_and_says_so() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#3366cc", "global": true})).unwrap();
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let e = s.execute("edit.colors.adjustBalance", &json!({"mode": "global"})).unwrap_err().to_string();
assert!(e.contains("global and spot"), "{e}");
assert_eq!(*s.doc().unwrap().doc, *before, "nothing changed");
let p = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint();
assert!(matches!(p, Paint::Solid { swatch: Some(ref n), .. } if n == "Brand"), "the link is kept");
}
#[test]
fn balance_previews_and_commits_one_step() {
let mut s = session();

View File

@ -32,7 +32,7 @@ fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint {
/// A three-stop freeform gradient with a 0.3 midpoint, partial opacity and a placed vector.
fn three_stops() -> GradientPaint {
let stop = |offset, color, opacity, midpoint| GradientStop { offset, color, opacity, midpoint };
let stop = |offset, color, opacity, midpoint| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) };
let mut g = GradientPaint::new(Gradient {
kind: GradientKind::Freeform,
stops: vec![

View File

@ -107,7 +107,10 @@ fn add_used_colors_skips_colours_that_have_swatches() {
run(&mut s, "select.set", json!({"ids": [d.0]}));
let r = run(&mut s, "swatch.addUsedColors", json!({"selection": true, "global": true}));
assert_eq!(r, json!({"added": ["R=171 G=205 B=239"], "linked": 1}));
assert_eq!(fill(&s, d), Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()) });
assert_eq!(
fill(&s, d),
Paint::Solid { color: vectorcraft_color::Color::from_hex("#abcdef").unwrap(), swatch: Some("R=171 G=205 B=239".into()), tint: 1.0 }
);
assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global);
}
@ -151,7 +154,7 @@ fn merge_keeps_the_first_and_relinks_the_others_art() {
let r = run(&mut s, "swatch.merge", json!({"names": ["Red", "Orange"]}));
assert_eq!(r, json!({"name": "Red", "merged": ["Orange"], "relinked": 1}));
assert!(doc(&s).swatch("Orange").is_none());
assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()) });
assert_eq!(fill(&s, b), Paint::Solid { color: red, swatch: Some("Red".into()), tint: 1.0 });
assert_eq!(undo_len(&s), undo + 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*doc(&s), before);

View File

@ -128,7 +128,7 @@ fn fill_of(s: &Session, id: NodeId) -> Paint {
}
fn linked(hex: &str, name: &str) -> Paint {
Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()) }
Paint::Solid { color: Color::from_hex(hex).unwrap(), swatch: Some(name.into()), tint: 1.0 }
}
#[test]
@ -310,7 +310,7 @@ fn tints_of_global_swatches_get_swatches_only_when_asked() {
let (_, b) = art(&mut s);
// A 50% tint of Brand on b's stroke.
let tint = Color::from_hex("#95b7d8").unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l| {
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).map_solid_paints_in(&[b], &mut |c, l, _| {
let hit = l.as_deref() == Some("Brand");
if hit {
*c = tint;
@ -322,7 +322,11 @@ fn tints_of_global_swatches_get_swatches_only_when_asked() {
run(&mut s, "edit.undo", json!({}));
let with = run(&mut s, "swatch.newGroup", json!({"fromArtwork": true, "includeTints": true}));
assert_eq!(with["swatches"], json!(["Brand", "R=255 G=0 B=0", "R=0 G=255 B=0", "R=149 G=183 B=216"]));
assert_eq!(doc(&s).node(b).unwrap().appearance.stroke_paint(), Paint::Solid { color: tint, swatch: Some("Brand".into()) }, "tints stay linked");
assert_eq!(
doc(&s).node(b).unwrap().appearance.stroke_paint(),
Paint::Solid { color: tint, swatch: Some("Brand".into()), tint: 1.0 },
"tints stay linked"
);
}
#[test]

View File

@ -121,7 +121,7 @@ fn reset_defaults_restores_missing_swatches_or_replaces_them() {
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [id]}));
run(&mut s, "paint.setFill", json!({"swatch": "Mine"}));
assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()) });
assert_eq!(s.paint.fill, Paint::Solid { color: Color::from_hex("#abcdef").unwrap(), swatch: Some("Mine".into()), tint: 1.0 });
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
let d = doc(&s);
assert_eq!(d.swatches_iter().map(|w| w.name.clone()).collect::<Vec<_>>(), defaults);

View File

@ -0,0 +1,182 @@
//! Tints of global and spot colours: applying a swatch at a tint, tints following swatch edits,
//! tint swatches, spot tints on their plate and in PDF, Adjust Color Balance's Global mode.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_render::proof;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
/// A selected rectangle.
fn rect(s: &mut Session) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap());
run(s, "select.set", json!({"ids": [id.0]}));
id
}
fn fill(s: &Session, id: NodeId) -> Paint {
doc(s).node(id).unwrap().appearance.fill_paint()
}
const MAGENTA: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.2 };
/// A document with a spot swatch "Ink" (magenta + 20 % black) and a global process "Brand".
fn inks() -> Session {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": MAGENTA, "spot": true}));
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#3366cc", "global": true}));
s
}
fn linked(color: Color, name: &str, tint: f32) -> Paint {
Paint::Solid { color, swatch: Some(name.into()), tint }
}
#[test]
fn a_swatch_applies_at_a_tint_and_the_tint_follows_swatch_edits() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 40}));
let brand = Color::from_hex("#3366cc").unwrap();
assert_eq!(fill(&s, a), linked(brand.tinted(0.4), "Brand", 0.4));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "color": "#cc3300"}));
assert_eq!(r["relinked"], 1);
let red = Color::from_hex("#cc3300").unwrap();
assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Brand", 0.4), "the 40% tint follows the edit");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
// Renaming keeps the tint; deleting unlinks it and the art keeps its colour.
run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Logo"}));
assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Logo", 0.4));
run(&mut s, "swatch.delete", json!({"name": "Logo"}));
assert_eq!(fill(&s, a), Paint::solid(red.tinted(0.4)), "unlinked: tint 100% of its own colour");
// The proxies report the tint; process colours have none.
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 25}));
assert_eq!(run(&mut s, "paint.proxies", json!({}))["fill"]["tint"], json!(0.25));
assert!(s.execute("paint.setFill", &json!({"swatch": "Black", "tint": 50})).unwrap_err().to_string().contains("process"));
assert!(s.execute("paint.setFill", &json!({"color": "#ff0000", "tint": 50})).is_err(), "a tint needs a swatch");
}
#[test]
fn new_swatch_saves_a_tint_swatch_linked_to_its_base() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40}));
let r = run(&mut s, "swatch.new", json!({}));
assert_eq!(r["name"], "Ink 40%");
let w = doc(&s).swatch("Ink 40%").unwrap();
assert_eq!((w.tint_of(), w.global, w.spot), (Some(("Ink", 0.4)), false, false), "its kind is its base's");
let listed = run(&mut s, "swatch.list", json!({}));
let row = listed["swatches"].as_array().unwrap().iter().find(|w| w["name"] == "Ink 40%").unwrap().clone();
assert_eq!((row["tintOf"].clone(), row["tint"].clone()), (json!("Ink"), json!(40.0)));
// `tint` alone: that tint of the fill's swatch.
assert_eq!(run(&mut s, "swatch.new", json!({"tint": 70}))["name"], "Ink 70%");
assert_eq!(run(&mut s, "swatch.new", json!({"swatch": "Brand", "tint": 10}))["name"], "Brand 10%");
// Applying a tint swatch links to the base at that tint.
let b = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink 70%"}));
assert_eq!(fill(&s, b), linked(MAGENTA.tinted(0.7), "Ink", 0.7));
// Editing the base recolours the tint swatch and the art; the tint swatch only takes a name.
run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 1, "m": 0, "y": 0, "k": 0}}));
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
assert_eq!(doc(&s).swatch("Ink 70%").unwrap().paint, linked(cyan.tinted(0.7), "Ink", 0.7));
assert_eq!(fill(&s, a), linked(cyan.tinted(0.4), "Ink", 0.4));
assert!(s.execute("swatch.edit", &json!({"name": "Ink 70%", "color": "#ff0000"})).is_err());
run(&mut s, "swatch.edit", json!({"name": "Ink 70%", "newName": "Pale Ink"}));
// A tint swatch is used where its tint of its base is.
let unused = run(&mut s, "swatch.unused", json!({}));
let names: Vec<&str> = unused["names"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(!names.contains(&"Pale Ink") && !names.contains(&"Ink 40%") && names.contains(&"Brand 10%"), "{names:?}");
// A tint asked for in another mode, or as a global colour, is a process copy.
run(&mut s, "select.set", json!({"ids": [a.0]}));
let r = run(&mut s, "swatch.new", json!({"global": true}));
assert!(doc(&s).swatch(r["name"].as_str().unwrap()).unwrap().tint_of().is_none());
}
#[test]
fn spot_tints_print_their_percentage_of_the_plate() {
let mut s = inks();
rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40}));
let d = doc(&s);
let c = vectorcraft_color::cms::active();
let Paint::Solid { color, .. } = fill(&s, s.doc().unwrap().selection.objects[0]) else { panic!() };
let ink = proof::inks(d, &c, &color, Some(("Ink", 0.4)), proof::Intent::RelativeColorimetric);
assert_eq!((ink.cmyk, ink.spot), ([0.0; 4], Some(("Ink".to_string(), 0.4))));
// A process colour linked to a global swatch separates into process inks.
assert!(proof::inks(d, &c, &color, Some(("Brand", 0.4)), proof::Intent::RelativeColorimetric).spot.is_none());
// PDF: the Separation value is the tint (102 / 255 = 0.4).
let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap();
let pdf = String::from_utf8_lossy(&bytes);
assert!(pdf.contains("/Separation/Ink/DeviceCMYK"), "a spot tint is a Separation");
assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value");
}
#[test]
fn global_mode_shifts_tints_of_linked_colours_only() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 50}));
run(&mut s, "paint.setStroke", json!({"color": "#808080"}));
let b = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "edit.colors.adjustBalance", json!({"tint": -30}));
assert_eq!(r["changed"], 2, "two linked fills; the grey stroke isn't global");
let tint_of = |p: Paint| match p {
Paint::Solid { color, swatch, tint } => (color, swatch, tint),
_ => panic!("{p:?}"),
};
let (color, swatch, tint) = tint_of(fill(&s, a));
assert!((tint - 0.2).abs() < 1e-6 && swatch.as_deref() == Some("Ink") && color == MAGENTA.tinted(tint), "{tint}");
let brand = Color::from_hex("#3366cc").unwrap();
let (color, swatch, tint) = tint_of(fill(&s, b));
assert!((tint - 0.7).abs() < 1e-6 && swatch.as_deref() == Some("Brand") && color == brand.tinted(tint), "{tint}");
assert_eq!(doc(&s).node(a).unwrap().appearance.stroke_paint(), Paint::solid(Color::from_hex("#808080").unwrap()));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// Clamped at 100%; nothing left to change leaves the document alone.
let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 100}));
assert_eq!(r["changed"], 2);
assert_eq!(fill(&s, a), linked(MAGENTA, "Ink", 1.0));
let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 10}));
assert_eq!(r["changed"], 0);
}
#[test]
fn tints_round_trip_through_the_native_format() {
let mut s = inks();
rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 35}));
run(&mut s, "swatch.new", json!({}));
let d = doc(&s).clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
let id = s.doc().unwrap().selection.objects[0];
assert_eq!(back.node(id).unwrap().appearance.fill_paint(), fill(&s, id));
assert_eq!(back.swatch("Ink 35%"), d.swatch("Ink 35%"));
}
#[test]
fn converting_the_document_colour_mode_keeps_tints_exact() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 30}));
run(&mut s, "object.convertDocumentColorMode", json!({"mode": "cmyk"}));
let base = doc(&s).global_color("Brand").unwrap();
assert!(matches!(base, Color::Cmyk { .. }));
assert_eq!(fill(&s, a), linked(base.tinted(0.3), "Brand", 0.3), "30 % of the converted swatch's inks");
}

View File

@ -2,6 +2,7 @@
use std::collections::HashMap;
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
use krilla::color::{cmyk, luma, rgb};
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
use krilla::geom::{Path, PathBuilder, Size, Transform};
@ -242,19 +243,22 @@ impl Exporter<'_> {
color(c)
}
/// A solid paint, as a Separation colour space when it's linked to a spot swatch (the
/// swatch's CMYK equivalent is the alternate space).
fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color {
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
let spot = swatch.and_then(|n| self.doc.swatch(n).filter(|s| s.spot)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc)));
let Some((name, sc)) = spot else { return self.col(c) };
/// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate
/// space); `None` when `name` isn't a spot colour.
fn separation(&self, name: &str) -> Option<SeparationSpace> {
let sw = self.doc.swatch(name).filter(|s| s.spot)?;
let cms = vectorcraft_color::cms::active();
let intent = cms.settings().intent;
let full = cms.to_cmyk(&sc, intent);
let total: f32 = full.iter().sum();
let tint = if total <= 1e-4 { 1.0 } else { (cms.to_cmyk(c, intent).iter().sum::<f32>() / total).clamp(0.0, 1.0) };
let full = cms.to_cmyk(&sw.paint.color()?, cms.settings().intent);
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into()
Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt))
}
/// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch.
fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color {
match link.and_then(|n| self.separation(n)) {
Some(space) => SepColor::new(q(tint), space).into(),
None => self.col(c),
}
}
/// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect.
@ -275,7 +279,7 @@ impl Exporter<'_> {
fn paint(&mut self, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
match p {
Paint::None => None,
Paint::Solid { color: c, swatch } => Some(self.solid(c, swatch.as_deref()).into()),
Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()),
Paint::Gradient(g) => {
let geom = g.resolve(bounds);
let mut stops: Vec<Stop> = Vec::new();

View File

@ -397,7 +397,7 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<Gra
keep.push(N);
let stop_at = |t: f32, offset: f32| -> GradientStop {
let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }
GradientStop::new(offset, color)
};
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
let (kind, geom, stops) = if *axial {

View File

@ -3,9 +3,10 @@
//! * **Proof Colors** post-processes the rendered frame: every pixel goes display sRGB → proof
//! space (CMYK press or RGB/colour-blindness simulation) → display, through a cached 17³ LUT.
//! * **Separations Preview** recolours the *document* before rendering: each paint colour is split
//! into process inks (colour-managed) plus a spot ink when it's linked to a spot swatch. With one
//! plate visible the plate renders as greyscale ink coverage (black = 100%); with several, the
//! visible inks are composited through the proof profile, spots multiplied on top.
//! into process inks (colour-managed), or a spot ink at the colour's tint when it's linked to a
//! spot swatch (solid colours and gradient stops alike). With one plate visible the plate
//! renders as greyscale ink coverage (black = 100%); with several, the visible inks are
//! composited through the proof profile, spots multiplied on top.
//! Placed raster images are not separated (limitation).
//! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that
//! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with
@ -72,26 +73,26 @@ fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> {
})
}
/// Separate one colour into inks. Colours linked to a spot swatch print on that plate only, with
/// the tint given by the colour's ink total relative to the swatch's.
pub fn inks(doc: &Document, c: &Cms, color: &Color, swatch: Option<&str>, intent: Intent) -> Inks {
if let Some(name) = swatch
&& let Some(sc) = spot_swatch(doc, name)
/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it.
pub type Link<'a> = Option<(&'a str, f32)>;
/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at
/// its tint.
pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks {
if let Some((name, tint)) = link
&& spot_swatch(doc, name).is_some()
{
let full: f32 = c.to_cmyk(sc, intent).iter().sum();
let this: f32 = c.to_cmyk(color, intent).iter().sum();
let tint = if full <= 1e-4 { 1.0 } else { (this / full).clamp(0.0, 1.0) };
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint)) };
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))) };
}
Inks { cmyk: c.to_cmyk(color, intent), spot: None }
}
fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Link) -> Color) {
match p {
Paint::Solid { color, swatch } => *color = f(color, swatch.as_deref()),
Paint::Solid { color, swatch, tint } => *color = f(color, swatch.as_deref().map(|s| (s, *tint))),
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
s.color = f(&s.color, None);
s.color = f(&s.color, s.swatch.as_deref().map(|n| (n, s.tint)));
}
}
_ => {}
@ -99,8 +100,8 @@ fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
}
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
/// points), keeping swatch links.
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
/// points) given its swatch link and tint, keeping the links.
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) {
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) => map_paint(&mut l.paint, f),
@ -129,7 +130,7 @@ pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Option<&str>) ->
}
/// Apply `f` to every colour in the document's art and symbol definitions.
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Option<&str>) -> Color) {
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -> Color) {
for l in &mut doc.layers {
map_node_colors(Arc::make_mut(l), f);
}
@ -162,8 +163,8 @@ fn multiply_overprints(a: &mut Arc<Node>) {
}
}
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, swatch: Option<&str>) -> Color {
let ink = inks(doc, c, color, swatch, proof.intent);
fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], color: &Color, link: Link) -> Color {
let ink = inks(doc, c, color, link, proof.intent);
let rgb = if visible.len() == 1 {
let name = &visible[0];
let v = match PROCESS_PLATES.iter().position(|p| p == name) {

View File

@ -15,7 +15,7 @@ fn render(d: &Document) -> Image {
}
fn stops(a: Color, b: Color) -> Vec<GradientStop> {
vec![GradientStop { offset: 0.0, color: a, opacity: 1.0, midpoint: 0.5 }, GradientStop { offset: 1.0, color: b, opacity: 1.0, midpoint: 0.5 }]
vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]
}
fn gradient(kind: GradientKind, a: Color, b: Color, angle: f64) -> Paint {

View File

@ -154,10 +154,7 @@ fn line_and_head_take_the_opacity_once() {
#[test]
fn a_gradient_runs_on_into_the_head() {
let stops = vec![
GradientStop { offset: 0.0, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::rgb(0.0, 0.0, 1.0), opacity: 1.0, midpoint: 0.5 },
];
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint);
let h = head_of(&d);

View File

@ -280,7 +280,7 @@ impl Importer {
.iter()
.map(|s| {
let c = s.color();
GradientStop { offset: s.offset().get(), color: Color::rgb8(c.red, c.green, c.blue), opacity: s.opacity().get(), midpoint: 0.5 }
GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }
})
.collect::<Vec<_>>()
};

View File

@ -1,9 +1,10 @@
//! Edit → Edit Colors → Adjust Color Balance: the colour mode (Gray, RGB, CMYK or Global), a slider
//! per channel (−100..100 %), Convert, Fill and Stroke, previewed live on the canvas; OK keeps the
//! preview as one undo step (`edit.colors.adjustBalance`).
//! per channel (−100..100 %; Global has one, Tint, for the tints of global and spot colours),
//! Convert, Fill and Stroke, previewed live on the canvas; OK keeps the preview as one undo step
//! (`edit.colors.adjustBalance`).
//!
//! Fields: `mode` (`gray`, `rgb`, `cmyk` or `global`), the channels `r`, `g`, `b`, `c`, `m`, `y`,
//! `k`, `gray`, then `convert`, `fill`, `stroke` and `preview`.
//! `k`, `gray`, `tint`, then `convert`, `fill`, `stroke` and `preview`.
use serde_json::{Value, json};
use vectorcraft_color::Color;
@ -33,13 +34,16 @@ const MODES: [Mode; 4] = [
Mode { id: "gray", label: "Grayscale", channels: &[("gray", "Black")] },
Mode { id: "rgb", label: "RGB", channels: &[("r", "Red"), ("g", "Green"), ("b", "Blue")] },
Mode { id: "cmyk", label: "CMYK", channels: &[("c", "Cyan"), ("m", "Magenta"), ("y", "Yellow"), ("k", "Black")] },
Mode { id: "global", label: "Global", channels: &[] },
Mode { id: "global", label: "Global", channels: &[("tint", "Tint")] },
];
/// Global mode: it shifts the tints of linked colours, which keep their model (no Convert).
const GLOBAL: &str = "global";
/// Open the dialog with every channel at 0, adjusting fills and strokes.
pub fn open(app: &mut VectorcraftApp) {
let fields = json!({
"mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0,
"mode": "rgb", "r": 0, "g": 0, "b": 0, "c": 0, "m": 0, "y": 0, "k": 0, "gray": 0, "tint": 0,
"convert": false, "fill": true, "stroke": true, "preview": true,
});
app.ui.dialog = Some(Dialog::new(KIND, fields));
@ -85,16 +89,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
d.fields.insert("mode".into(), json!(MODES[i].id));
}
ui.add_space(12.0);
if widgets::check(ui, "Convert", d.bool("convert"), !m.channels.is_empty()) {
if widgets::check(ui, "Convert", d.bool("convert"), m.id != GLOBAL) {
d.fields.insert("convert".into(), json!(!d.bool("convert")));
}
});
ui.add_space(8.0);
let channels = mode(d).channels;
if channels.is_empty() {
widgets::dim_label(ui, "Global shifts the tints of global and spot colors (not available yet).");
let m = mode(d);
if m.id == GLOBAL {
widgets::dim_label(ui, "Shifts the tints of global and spot colors; other colors stay.");
}
for (k, label) in channels {
for (k, label) in m.channels {
form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x));
}
ui.add_space(8.0);
@ -166,15 +170,22 @@ mod tests {
}
#[test]
fn global_mode_reports_why_and_stays_open() {
fn global_mode_shifts_the_tints_of_global_colours() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap();
app.run("ui.colorBalanceDialog", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("global"));
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("mode".into(), json!("global"));
d.fields.insert("tint".into(), json!(-20));
frame(&mut app);
let e = super::super::confirm(&mut app).unwrap_err();
assert!(e.contains("global"), "{e}");
assert!(app.ui.dialog.is_some());
assert_eq!(params(app.ui.dialog.as_ref().unwrap())["tint"], json!(-20.0));
super::super::confirm(&mut app).unwrap();
let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
let vectorcraft_color::Paint::Solid { swatch, tint, .. } = n.appearance.fill_paint() else { panic!() };
assert_eq!(swatch.as_deref(), Some("Ink"), "the colour stays linked");
assert!((tint - 0.3).abs() < 1e-6, "{tint}");
}
}

View File

@ -84,7 +84,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let s = stops[i];
let s = &stops[i];
widgets::dim_label(ui, "Opacity:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) {
let mut v2 = stops.clone();

View File

@ -1,10 +1,10 @@
//! New Swatch: the Swatch Options fields (name, colour type, Global, mode, sliders and hex)
//! prefilled from the active fill or stroke; a gradient or pattern only takes a name. OK runs
//! `swatch.new`.
//! prefilled from the active fill or stroke; a gradient, a pattern or a tint of a global colour
//! (saved as a tint swatch, "Name 40%") only takes a name. OK runs `swatch.new`.
//!
//! Fields: `name`, `spot`, `global`, `mode` and `color` as in Swatch Options, `group` (the colour
//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient or
//! pattern; absent for a colour) and `__auto` (the default name, which follows the colour until
//! group the colour goes into; empty for none), `__paint` (the `swatch.new` paint of a gradient,
//! pattern or tint; absent for a colour) and `__auto` (the default name, which follows the colour until
//! the name is edited).
use serde_json::{Value, json};
@ -26,6 +26,9 @@ pub fn open(app: &mut VectorcraftApp, spot: bool, group: Option<&str>) -> Result
let paint = crate::panels::active_paint(app);
let name = app.session.active().ok_or("no document open")?.doc.new_swatch_name(&paint);
let mut fields = match &paint {
Paint::Solid { swatch: Some(_), tint, .. } if *tint < 1.0 => {
json!({"__paint": crate::panels::paint_params(&paint), "group": group.unwrap_or_default()})
}
Paint::Solid { color, .. } => {
json!({"color": color, "mode": mode_id(Mode::of(color)), "spot": spot, "global": spot, "group": group.unwrap_or_default()})
}
@ -82,4 +85,18 @@ mod tests {
let d = Dialog::new(KIND, json!({"name": "Dots", "__paint": {"pattern": "Dots"}, "group": ""}));
assert_eq!(params(&d), json!({"name": "Dots", "pattern": "Dots"}));
}
#[test]
fn a_tint_opens_as_a_named_tint_swatch() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap();
open(&mut app, false, None).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert_eq!(params(&d), json!({"name": "Ink 40%", "swatch": "Ink", "tint": 40.0}));
run_and_close(&mut app, "swatch.new", params(&d)).unwrap();
let w = app.session.doc().unwrap().doc.swatch("Ink 40%").cloned().unwrap();
assert_eq!(w.tint_of(), Some(("Ink", 0.4)));
}
}

View File

@ -53,10 +53,18 @@ fn set_color(d: &mut Dialog, c: Color) {
d.fields.insert("color".into(), json!(c));
}
/// Open the editor of swatch `name`: Swatch Options for a colour or a gradient (its name only),
/// pattern editing for a pattern.
/// Open the editor of swatch `name`: Swatch Options for a colour (a tint swatch's base) or a
/// gradient (its name only), pattern editing for a pattern.
pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
let sw = app.session.active().and_then(|st| st.doc.swatch(name).cloned()).ok_or_else(|| format!("no swatch `{name}`"))?;
let d = app.session.active().map(|st| &st.doc);
let sw = d.and_then(|d| d.swatch(name)).ok_or_else(|| format!("no swatch `{name}`"))?;
if let Some((base, _)) = sw.tint_of()
&& d.is_some_and(|d| d.swatch(base).is_some())
{
let base = base.to_string();
return open(app, &base);
}
let sw = sw.clone();
match &sw.paint {
Paint::Solid { color, .. } => {
let fields = json!({

View File

@ -211,7 +211,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.colorBalanceDialog",
"Adjust Color Balance…",
"",
"{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step",
"{} open Adjust Colors for the selection (dialog `colorBalance`, fields `mode` gray|rgb|cmyk|global, channels r g b / c m y k / gray / tint (global) −100..100, `convert`, `fill`, `stroke`, `preview`): previews live, OK runs edit.colors.adjustBalance as one undo step",
),
(
"ui.saturateDialog",

View File

@ -1,7 +1,9 @@
//! Color panel: Grayscale / RGB / HSB / CMYK / Web Safe RGB sliders with colour-gradient tracks,
//! value fields, hex field, None/Black/White chips, spectrum ramp and the Fill/Stroke proxy.
//! When the active paint is a gradient, the sliders edit the Gradient panel's selected stop (on a
//! freeform gradient, its selected point).
//! freeform gradient, its selected point). A colour (or stop) linked to a global or spot swatch
//! shows Tint mode instead: the swatch's chip and name and a T slider (0–100 % of the swatch);
//! picking a mode from the menu makes it a process colour.
//!
//! The mode follows the colour's own model (the last mode picked comes back for colours in its
//! model). Alt-clicking the spectrum or a chip paints the inactive proxy; Shift-clicking the
@ -259,27 +261,53 @@ fn to32(c: &Color) -> Color32 {
super::c32(c)
}
/// What the panel is editing: the active proxy's solid colour, a selected gradient stop or a
/// selected freeform point.
/// The global or spot swatch a colour is a tint of (Tint mode).
struct Tint {
swatch: String,
spot: bool,
/// 0..1.
tint: f32,
/// The swatch's colour (100 %).
base: Color,
}
impl Tint {
/// The tint `link` (a swatch name) and `tint` show: `None` unless the swatch is a global colour.
fn of(app: &VectorcraftApp, link: Option<&str>, tint: f32) -> Option<Self> {
let name = link?;
let d = &app.session.active()?.doc;
let spot = d.swatch(name)?.spot;
Some(Self { swatch: name.to_string(), spot, tint, base: d.global_color(name)? })
}
}
/// What the panel is editing: the active proxy's solid colour or a selected gradient stop (with
/// the global swatch it is a tint of), or a selected freeform point.
enum Target {
Paint(Option<Color>),
Stop { paint: Paint, index: usize, color: Color },
Paint(Option<Color>, Option<Tint>),
Stop { paint: Paint, index: usize, color: Color, tint: Option<Tint> },
Point { index: usize, color: Color },
}
impl Target {
fn color(&self) -> Option<Color> {
match self {
Target::Paint(c) => *c,
Target::Paint(c, _) => *c,
Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color),
}
}
fn tint(&self) -> Option<&Tint> {
match self {
Target::Paint(_, t) | Target::Stop { tint: t, .. } => t.as_ref(),
Target::Point { .. } => None,
}
}
}
/// The active proxy's colour or gradient stop; no colour for None, patterns and a "?" proxy.
fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
if super::active_mixed(app, ui.ctx()) {
return Target::Paint(None);
return Target::Paint(None, None);
}
let p = active_paint(app);
match &p {
@ -291,11 +319,11 @@ fn target(app: &VectorcraftApp, ui: &Ui) -> Target {
}
Paint::Gradient(g) => {
let i = app.session.selected_stop().unwrap_or(0).min(g.gradient.stops.len().saturating_sub(1));
let color = g.gradient.stops.get(i).map(|s| s.color).unwrap_or(Color::BLACK);
Target::Stop { paint: p.clone(), index: i, color }
let (color, tint) = g.gradient.stops.get(i).map_or((Color::BLACK, None), |s| (s.color, Tint::of(app, s.swatch.as_deref(), s.tint)));
Target::Stop { paint: p.clone(), index: i, color, tint }
}
Paint::Solid { color, .. } => Target::Paint(Some(*color)),
_ => Target::Paint(None),
Paint::Solid { color, swatch, tint } => Target::Paint(Some(*color), Tint::of(app, swatch.as_deref(), *tint)),
_ => Target::Paint(None, None),
}
}
@ -306,7 +334,7 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => {
let mut stops = g.gradient.stops.clone();
if let Some(s) = stops.get_mut(*index) {
s.color = c;
s.set_color(c, None);
}
super::gradient::set_stops(app, &stops, None, phase);
}
@ -322,17 +350,59 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
}
}
/// Switch the panel to `mode`, converting the colour to its model.
/// Set the target's tint of its swatch to `t` (0..1), keeping the link (the T slider).
fn apply_tint(app: &mut VectorcraftApp, tgt: &Target, tint: &Tint, t: f32, phase: Live) {
match tgt {
Target::Stop { paint: Paint::Gradient(g), index, .. } => {
let mut stops = g.gradient.stops.clone();
if let Some(s) = stops.get_mut(*index) {
s.set_color(tint.base.tinted(t), Some((tint.swatch.clone(), t)));
}
super::gradient::set_stops(app, &stops, None, phase);
}
_ => {
let cmd = super::proxy_cmd(app, false);
live_run(app, "Color", cmd, json!({"swatch": tint.swatch, "tint": t * 100.0}), phase);
}
}
}
/// Switch the panel to `mode`, converting the colour to its model (a tint becomes a process
/// colour).
fn set_mode(app: &mut VectorcraftApp, ctx: &egui::Context, tgt: &Target, mode: Mode) {
set_pstate(ctx, "color-mode", Some(mode));
if let Some(c) = tgt.color() {
let conv = convert_to(mode, &c);
if conv != c {
if conv != c || tgt.tint().is_some() {
apply(app, tgt, conv, Live::Released, false);
}
}
}
/// Tint mode's rows: the swatch's chip and name, and the T slider and field. Returns a new tint
/// (0..1) and its phase.
fn tint_rows(ui: &mut Ui, tint: &Tint, slider_w: f32, field_w: f32) -> Option<(f32, Live)> {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
widgets::swatch_tile(ui, r, &Paint::solid(tint.base), false, false);
super::swatches::global_mark(ui, r, tint.spot);
ui.label(egui::RichText::new(&tint.swatch).size(12.5).color(t.text));
});
ui.horizontal(|ui| {
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("T").size(12.5).color(t.text)));
let track = |x: f32| to32(&tint.base.tinted(x));
let (nv, phase) = widgets::color_slider(ui, ("color-slider", "tint"), tint.tint, slider_w, &track);
let field = widgets::plain_field(ui, ("color-field", "tint"), (tint.tint * 100.0).round() as f64, "%", 0, field_w);
match (nv, field) {
(Some(v), _) => Some(((v * 100.0).round() / 100.0, phase)),
(None, Some(v)) => Some(((v as f32 / 100.0).clamp(0.0, 1.0), Live::Released)),
_ => None,
}
})
.inner
}
/// What happened on the spectrum ramp.
enum SpectrumHit {
Pick(Color, Live),
@ -391,6 +461,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Alt held for the inactive proxy (`behind`).
let mut new: Option<(Color, Live, Vec<f32>)> = None;
let mut behind: Option<(Color, Live)> = None;
let mut tinted: Option<(f32, Live)> = None;
let gamut = color.filter(|_| matches!(mode, Mode::Rgb | Mode::Hsb)).and_then(|c| gamut_fix(app, ui.ctx(), "color-gamut", &c));
ui.horizontal(|ui| {
// Left column: proxy, out-of-gamut and out-of-web warnings.
@ -412,6 +483,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let shift = ui.input(|i| i.modifiers.shift);
let field_w = 48.0;
let slider_w = (ui.available_width() - field_w - 24.0).max(60.0);
if let Some(tint) = tgt.tint() {
tinted = tint_rows(ui, tint, slider_w, field_w);
return;
}
let enabled = color.is_some();
for (i, lbl) in mode.labels().iter().enumerate() {
ui.horizontal(|ui| {
@ -489,6 +564,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if let Some((c, phase)) = behind {
apply(app, &tgt, c, phase, true);
}
if let (Some((t, phase)), Some(tint)) = (tinted, tgt.tint()) {
apply_tint(app, &tgt, tint, t, phase);
}
if let Some((c, phase, comps2)) = new {
let key = (c.to_hex(), mode as u8);
set_pstate(ui.ctx(), "color-comps", Some((key, comps2)));
@ -504,9 +582,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.separator();
let tgt = target(app, ui);
let color = tgt.color();
// Tint mode checks no colour mode: picking one makes the tint a process colour.
let cur = display_mode(pstate(ui.ctx(), "color-mode"), color.as_ref());
for (m, label) in Mode::ALL {
if menu_item(ui, label, true, m == cur) {
if menu_item(ui, label, true, m == cur && tgt.tint().is_none()) {
set_mode(app, ui.ctx(), &tgt, m);
}
}
@ -531,7 +610,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, "Create New Swatch…", color.is_some(), false)
&& let Some(c) = color
{
app.run("swatch.new", json!({"color": color_json(&c)})).ok();
// A tint saves a tint swatch ("Name 40%").
let p = match tgt.tint() {
Some(t) => json!({"swatch": t.swatch, "tint": t.tint * 100.0}),
None => json!({"color": color_json(&c)}),
};
app.run("swatch.new", p).ok();
}
if menu_item(ui, "Copy Color Value (Hex)", color.is_some(), false)
&& let Some(c) = color
@ -691,6 +775,54 @@ mod tests {
assert!(d.bool("stroke"));
}
#[test]
fn a_global_colour_shows_tint_mode_and_the_t_slider_sets_its_tint() {
let mut app = app();
let ctx = egui::Context::default();
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap();
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 50})).unwrap();
frame(&mut app, &ctx, vec![], egui::Modifiers::NONE);
let _ = ctx.run_ui(Default::default(), |ui| {
let tgt = target(&app, ui);
let t = tgt.tint().expect("Tint mode");
assert_eq!((t.swatch.as_str(), t.spot, t.tint, t.base), ("Ink", true, 0.5, Color::cmyk(0.0, 1.0, 0.0, 0.0)));
});
// The chip's name and the T row are drawn; a click on the slider's right part raises the
// tint and keeps the link.
let texts = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(260.0, 600.0));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
v
};
let drawn = texts(&mut app, vec![]);
let at = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, p)| *p);
assert!(at("Ink").is_some(), "the swatch's name: {drawn:?}");
let (t_label, field) = (at("T").expect("the T row"), at("50%").expect("the tint field"));
let pos = egui::pos2(field.x - 40.0, t_label.y);
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
texts(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]);
texts(&mut app, vec![button(false)]);
let Paint::Solid { swatch, tint, .. } = crate::panels::current_paints(&app).0 else { panic!() };
assert_eq!(swatch.as_deref(), Some("Ink"));
assert!(tint > 0.6 && tint < 1.0, "{tint}");
// Picking a mode makes it a process colour.
let _ = ctx.run_ui(Default::default(), |ui| {
let tgt = target(&app, ui);
set_mode(&mut app, ui.ctx(), &tgt, Mode::Cmyk);
});
assert!(matches!(crate::panels::current_paints(&app).0, Paint::Solid { swatch: None, .. }));
}
#[test]
fn hide_options_leaves_the_proxy_and_spectrum() {
let mut app = app();

View File

@ -365,7 +365,7 @@ fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: boo
let stops = &g.stops;
let sel = app.session.selected_stop().filter(|i| *i < stops.len() && is_grad);
let mid = selected_mid(app, ui.ctx(), stops.len()).filter(|_| is_grad);
let stop = sel.map(|i| stops[i]);
let stop = sel.map(|i| &stops[i]);
if let (Some(i), Some(s)) = (sel, stop) {
ui.horizontal(|ui| {
widgets::dim_label(ui, "Color:");

View File

@ -378,10 +378,12 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
/// A colour as command JSON, keeping its model.
pub(crate) use vectorcraft_tools::params::color_json;
/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` (lossless) or `{pattern}`).
/// Paint as command params (`{color}`, `{swatch, tint?}`, `{none}`, `{gradient}` (lossless) or
/// `{pattern}`).
pub(crate) fn paint_params(p: &Paint) -> Value {
match p {
Paint::None => json!({"none": true}),
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => json!({"swatch": n, "tint": tint * 100.0}),
Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}),
Paint::Solid { color, .. } => json!({"color": color_json(color)}),
Paint::Gradient(g) => json!({"gradient": vectorcraft_tools::params::gradient_params(g)}),
@ -423,6 +425,8 @@ mod tests {
assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2);
assert_eq!(paint_params(&vectorcraft_doc::pattern::pattern_paint("Dots")), json!({"pattern": "Dots"}));
assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5));
let tint = Paint::Solid { color: Color::gray(0.5), swatch: Some("Ink".into()), tint: 0.5 };
assert_eq!(paint_params(&tint), json!({"swatch": "Ink", "tint": 50.0}), "a tint keeps its percentage");
}
}
#[cfg(test)]

View File

@ -116,7 +116,11 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec<Entry> {
let q = query.trim().to_lowercase();
let found = |n: &str| q.is_empty() || n.to_lowercase().contains(&q);
let mut out = vec![];
let sw = |s: &vectorcraft_color::Swatch| Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global: s.global, spot: s.spot };
// A tint swatch shows its base's kind (global or spot).
let sw = |s: &vectorcraft_color::Swatch| {
let (global, spot) = s.tint_of().and_then(|(base, _)| d.swatch(base)).map_or((s.global, s.spot), |b| (b.global, b.spot));
Entry::Swatch { name: s.name.clone(), paint: s.paint.clone(), global, spot }
};
// None first, then Registration, then the rest (the reference app's order).
let (specials, rest): (Vec<_>, Vec<_>) = d.swatches.iter().partition(|s| s.paint.is_none());
for s in specials.iter().filter(|s| kind.accepts(&s.paint, false) && found(&s.name)) {
@ -616,12 +620,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
widgets::subheader(ui, "Swatch Tiles");
let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), "Find") } else { String::new() };
let items = entries(app, kind, &query);
let active = active_paint(app);
let active_swatch = match &active {
Paint::Solid { swatch: Some(n), .. } => Some(n.clone()),
Paint::None => Some("[None]".to_string()),
_ => None,
};
let active_swatch = active_swatch(app);
let selected = selection(app, ui);
// Without a selection the swatch of the active paint is highlighted.
let fallback: Vec<String> = if selected.is_empty() { active_swatch.into_iter().collect() } else { vec![] };
@ -693,6 +692,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
bottom(app, ui, &selected);
}
/// The swatch the active paint comes from: a linked colour's global swatch (a tint's tint swatch
/// when there is one), or None.
fn active_swatch(app: &VectorcraftApp) -> Option<String> {
match active_paint(app) {
Paint::Solid { swatch: Some(n), tint, .. } => {
let d = &app.session.active()?.doc;
let tint_swatch = d.swatches_iter().find(|w| w.tint_of() == Some((n.as_str(), tint)));
Some(tint_swatch.map_or(n.clone(), |w| w.name.clone()))
}
Paint::None => Some("[None]".to_string()),
_ => None,
}
}
/// The thumbnail cells of `items` for `view` in the full available width, row by row; a folder
/// (`is_folder`) starts a new row.
pub(crate) fn tile_grid<'a, T>(ui: &mut Ui, items: &'a [T], view: View, is_folder: impl Fn(&T) -> bool) -> Vec<(&'a T, Rect)> {
@ -1180,7 +1193,7 @@ mod tests {
let doc = &app.session.doc().unwrap().doc;
assert_eq!(doc.swatch("Signal").and_then(|w| w.paint.color()).map(|c| c.to_hex()), Some("#00ff00".into()));
let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()) });
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#00ff00").unwrap(), swatch: Some("Signal".into()), tint: 1.0 });
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
}
@ -1309,7 +1322,7 @@ mod tests {
let g = doc.swatch_groups.iter().find(|g| g.name == "Color Group 2").expect("group made");
assert_eq!(g.swatches.iter().map(|w| (w.name.as_str(), w.global)).collect::<Vec<_>>(), [("R=18 G=52 B=86", true)]);
let fill = doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()) });
assert_eq!(fill, Paint::Solid { color: Color::from_hex("#123456").unwrap(), swatch: Some("R=18 G=52 B=86".into()), tint: 1.0 });
}
/// The centre of the tile or row of `name` as laid out by the last frame.
@ -1425,10 +1438,8 @@ mod tests {
let ctx = context();
frame(&mut app, &ctx, vec![], 0.0, show);
let grad = |c: &str| {
let stops = vec![
vectorcraft_color::GradientStop { offset: 0.0, color: Color::from_hex(c).unwrap(), opacity: 1.0, midpoint: 0.5 },
vectorcraft_color::GradientStop { offset: 1.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
];
let stops =
vec![vectorcraft_color::GradientStop::new(0.0, Color::from_hex(c).unwrap()), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)];
Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient { kind: GradientKind::Radial, stops })))
};
let drop_on = |app: &mut VectorcraftApp, name: &str, paint: Paint, m: Modifiers, time: f64| {
@ -1470,6 +1481,21 @@ mod tests {
assert!(!Kind::Groups.accepts(&solid, false));
}
#[test]
fn the_active_tint_swatch_is_highlighted_and_a_tint_edits_its_base() {
let mut app = app();
app.run("swatch.new", json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 40})).unwrap();
assert_eq!(active_swatch(&app).as_deref(), Some("Ink"), "no tint swatch yet: its base");
app.run("swatch.new", json!({})).unwrap();
assert_eq!(active_swatch(&app).as_deref(), Some("Ink 40%"));
let tint = entries(&app, Kind::Color, "Ink 40").into_iter().find(|e| e.name() == "Ink 40%").unwrap();
assert!(matches!(tint, Entry::Swatch { global: true, spot: true, .. }), "a tint swatch shows its base's kind");
app.run("ui.swatchOptions", json!({"name": "Ink 40%"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("__swatch").as_str()), (KIND, "Ink"), "a tint swatch opens its base's options");
}
#[test]
fn medium_tiles_match_measured_metrics() {
assert_eq!(View::MediumThumb.tile(), (15.5, 17.0));

View File

@ -76,7 +76,7 @@ as `ui.color_guide` (`variation`, `steps`, `amount`). Save Colors as Swatches ru
(one swatch per colour, one undo step).
Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/
`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` from −100 to 100, `convert`, `fill`, `stroke`,
`global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`,
`preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the
canvas while open; `ui.dialog.confirm` keeps the result as one undo step (`edit.colors.adjustBalance` /
`edit.colors.saturate`) and `ui.dialog.cancel` rolls it back. Global mode answers with an error until tints of global

View File

@ -428,3 +428,19 @@ fills and strokes become objects among its members, and its members are expanded
```json
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{"ids":[12]}}}
```
## Tints of global and spot colours
A colour linked to a global or spot swatch has a tint (the reference app's T slider): `paint.setFill {swatch,
tint?: 0..100}` (and `paint.setStroke`) applies the swatch at that percentage, linked, so `swatch.edit` recolours it
at its own tint. `paint.proxies` and `document.inspect` show the paint as `{type: "solid", color, swatch, tint}`
(`tint` 0..1, left out at 100 %). `swatch.new {tint?}` with the current fill (or `swatch`) saves a tint swatch,
"Name 40%": `swatch.list` reports it with `tintOf` and `tint`, applying it links to its base at that tint, and it
follows edits of its base. A spot tint prints that percentage of its plate (Separations Preview, PDF Separation
value). `edit.colors.adjustBalance {tint: -100..100}` (Global mode) shifts the tints of the selection's linked
colours and leaves the rest alone.
```json
{"name":"run_command","arguments":{"command":"paint.setFill","params":{"swatch":"Ink","tint":40}}}
{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"global","tint":-20}}}
```