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:29:17 +07:00
co-authored by Claude Opus 5.5
parent e94748dc17
commit b2ececc7b4
42 changed files with 918 additions and 277 deletions
+67 -35
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)"))),
};
+9
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 }))
+1 -1
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 });
+2
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<_>>>()?;
+38 -12
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)
}
+61 -29
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
};
+2 -2
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 }))
}
+1 -1
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 }))
}
};
+2
View File
@@ -847,4 +847,6 @@ mod tests_textedit;
#[cfg(test)]
mod tests_tileedge;
#[cfg(test)]
mod tests_tints;
#[cfg(test)]
mod tests_xform;
+1 -1
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 { .. }));
-15
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();
+1 -1
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![
+5 -2
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);
+7 -3
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]
+1 -1
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);
+182
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");
}