mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e94748dc17
commit
b2ececc7b4
@@ -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)"))),
|
||||
};
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<_>>>()?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -847,4 +847,6 @@ mod tests_textedit;
|
||||
#[cfg(test)]
|
||||
mod tests_tileedge;
|
||||
#[cfg(test)]
|
||||
mod tests_tints;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
||||
@@ -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 { .. }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user