Fix #4923: Find Nearest Swatch matches colours to a library's swatches

Object > Plug-ins > Color Effects > Find Nearest Swatch... picks a
library (the document's swatches, a built-in library, or a library or
colour book the user saved or loaded) and Fill and Stroke, and lists
each colour of the selection with its three closest swatches and their
CIEDE2000 difference. Apply gives each colour its nearest swatch as one
undo step.

- addon.color.nearestSwatch (a query): the closest swatches of a
  library to each colour of the selection's fills and strokes (solid
  colours and gradient stops, inside groups and type), or to a colour
  given, with deltaE; count 1..50.
- addon.color.applyNearestSwatch: recolours those paints (and gradient
  meshes) to their nearest swatch; global and spot swatches link, and a
  library's swatches the colours take are added to the document first
  (as swatch.library.add adds them), so a spot colour stays spot.
- The library picker Image Trace's Document Library uses moves to the
  Swatches panel module and is shared.
- No colour book data ships: only libraries VectorCraft computes or the
  user loads are searched.
This commit is contained in:
bflatastic
2026-11-01 11:09:11 +07:00
parent 6d6757c929
commit a76d9215da
43 changed files with 737 additions and 29 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str
}
/// Whole-number param `key` within `lo..=hi`, `None` when left out.
fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> {
pub(super) fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?;
if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
+22 -2
View File
@@ -2,9 +2,11 @@
//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as
//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the
//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model,
//! and Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print. Its panel and
//! its submenu, Object › Plug-ins › Color Effects, are the UI's.
//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, and Find Nearest
//! Swatch ([`nearest`]) matches colours to a swatch library's. Its panel and its submenu, Object ›
//! Plug-ins › Color Effects, are the UI's.
mod nearest;
mod print_fixes;
#[cfg(test)]
mod tests;
@@ -50,6 +52,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
print_fixes::run
),
cmd!(
query "addon.color.nearestSwatch",
"Find Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}",
has_doc,
nearest::find
),
cmd!(
"addon.color.applyNearestSwatch",
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
]
}
@@ -0,0 +1,198 @@
//! Find Nearest Swatch (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`; Object ›
//! Plug-ins › Color Effects › Find Nearest Swatch…): the swatches of a library, a colour book the
//! user loaded or the document closest to each colour of the selection (CIEDE2000), and the
//! colours replaced by them as one undo step (#4923).
use serde_json::{Value, json};
use vectorcraft_color::cms::{Lab, delta_e2000};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::color_name;
use vectorcraft_doc::{Document, NodeId, NodeKind};
use super::super::super::colorcmds::recolor_links;
use super::super::super::menucmds::squash;
use super::super::super::swatchlib::{DOCUMENT_SWATCHES, library_swatches};
use super::super::super::{bad, checked_ids_or_selection, color_value, index, switch_or, text_param};
use super::super::arrange::whole_in;
use crate::{Result, Session};
const FIND: &str = "addon.color.nearestSwatch";
const APPLY: &str = "addon.color.applyNearestSwatch";
/// The most colours of the selection looked up.
const MAX_COLORS: usize = 100;
/// The `library` param ([`DOCUMENT_SWATCHES`] when left out) and its colour swatches with their Lab
/// values.
fn candidates(s: &Session, p: &Value, cmd: &str) -> Result<(String, Vec<(Swatch, Lab)>)> {
let key = text_param(p, "library", cmd)?.unwrap_or(DOCUMENT_SWATCHES).to_string();
let swatches = library_swatches(s, &key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?;
let labs: Vec<(Swatch, Lab)> = swatches
.into_iter()
.filter(|w| !w.is_reserved())
.filter_map(|w| {
let lab = w.paint.color()?.to_lab();
Some((w, lab))
})
.collect();
if labs.is_empty() {
return Err(bad(cmd, format!("`{key}` has no colours")));
}
Ok((key, labs))
}
/// The `count` swatches of `among` closest to `c`, closest first, with their difference (ΔE 2000).
fn nearest(among: &[(Swatch, Lab)], c: Color, count: usize) -> Vec<(&Swatch, f32)> {
let lab = c.to_lab();
let mut v: Vec<(&Swatch, f32)> = among.iter().map(|(w, l)| (w, delta_e2000(*l, lab))).collect();
v.sort_by(|a, b| a.1.total_cmp(&b.1));
v.truncate(count);
v
}
/// The solid colours and gradient stops of the fills (`fill`) and strokes (`stroke`) of `ids`, of
/// everything inside them and of their type, once each and at most [`MAX_COLORS`].
fn art_colors(d: &Document, ids: &[NodeId], fill: bool, stroke: bool) -> Vec<Color> {
let mut out: Vec<Color> = vec![];
let mut add = |p: &Paint| {
let colors: Vec<Color> = match p {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
_ => vec![],
};
for c in colors {
if out.len() < MAX_COLORS && !out.contains(&c) {
out.push(c);
}
}
};
for n in ids.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |m| {
for it in &m.appearance.items {
if if it.is_fill() { fill } else { stroke } {
add(it.paint());
}
}
if let NodeKind::Text(t) = &m.kind {
for r in &t.runs {
if fill {
add(&r.style.fill);
}
if stroke {
add(&r.style.stroke);
}
}
}
});
}
out
}
/// A swatch's name and values as the results give them.
fn described(w: &Swatch) -> Value {
let c = w.paint.color().unwrap_or_default();
json!({ "name": w.name, "values": color_name(c), "hex": c.to_hex(), "spot": w.spot })
}
pub(super) fn find(s: &mut Session, p: &Value) -> Result<Value> {
let count = whole_in(p, "count", (1, 50), FIND)?.map_or(5, index);
let (fill, stroke) = (switch_or(p, "fill", true, FIND)?, switch_or(p, "stroke", true, FIND)?);
let colors = match p.get("color").filter(|v| !v.is_null()) {
Some(v) => vec![color_value(v).ok_or_else(|| bad(FIND, "`color` is not a colour"))?],
None => {
let ids = checked_ids_or_selection(s, p, FIND)?;
if ids.is_empty() {
return Err(bad(FIND, "select objects or give `color`"));
}
art_colors(&s.doc()?.doc, &ids, fill, stroke)
}
};
let (library, among) = candidates(s, p, FIND)?;
let found: Vec<Value> = colors
.iter()
.map(|c| {
let matches: Vec<Value> = nearest(&among, *c, count)
.into_iter()
.map(|(w, de)| {
let mut m = described(w);
m["deltaE"] = json!((f64::from(de) * 100.0).round() / 100.0);
m
})
.collect();
json!({ "values": color_name(*c), "hex": c.to_hex(), "matches": matches })
})
.collect();
Ok(json!({ "library": library, "colors": found }))
}
pub(super) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let (fill, stroke) = (switch_or(p, "fill", true, APPLY)?, switch_or(p, "stroke", true, APPLY)?);
let ids = checked_ids_or_selection(s, p, APPLY)?;
if ids.is_empty() {
return Err(bad(APPLY, "nothing selected"));
}
let (library, among) = candidates(s, p, APPLY)?;
// The swatch each colour takes, once each.
let colors = art_colors(&s.doc()?.doc, &ids, fill, stroke);
let mut used: Vec<&Swatch> = vec![];
for c in &colors {
if let Some((w, _)) = nearest(&among, *c, 1).first()
&& !used.iter().any(|u| u.name == w.name)
{
used.push(w);
}
}
if used.is_empty() {
return Ok(json!({ "changed": 0, "swatches": [], "added": [] }));
}
let undo = s.doc()?.history.undo.len();
// A library's swatches join the document's first, as Add to Swatches adds them, so spot and
// global colours stay linked to theirs.
let mut added = vec![];
let mut names: Vec<String> = used.iter().map(|w| w.name.clone()).collect();
if library != DOCUMENT_SWATCHES {
let r = s.execute("swatch.library.add", &json!({ "library": library, "names": names }))?;
let listed = |k: &str| r.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
let in_doc: Vec<String> = listed("added").iter().chain(&listed("existing")).filter_map(Value::as_str).map(str::to_string).collect();
let d = &s.doc()?.doc;
// Each library swatch's copy: the document swatch with its paint among those added or kept.
names = used
.iter()
.map(|w| in_doc.iter().find(|n| d.swatch(n).is_some_and(|x| x.paint == w.paint)).cloned().unwrap_or_else(|| w.name.clone()))
.collect();
added = listed("added");
}
// Each used swatch's paint as art takes it: linked to a global or spot swatch.
let d = &s.doc()?.doc;
let paints: Vec<(Lab, Paint)> = used
.iter()
.zip(&names)
.filter_map(|(w, name)| {
let lab = w.paint.color()?.to_lab();
let paint = d.swatch(name).filter(|x| x.global).and_then(|_| d.tint_paint(name, 1.0)).unwrap_or_else(|| w.paint.clone());
Some((lab, paint))
})
.collect();
// shortcut: a colour past the MAX_COLORS looked up takes the closest of the swatches those
// took; look up every colour if documents with more colours need their own matches.
let to = |c: Color| -> Option<&Paint> {
let lab = c.to_lab();
paints.iter().min_by(|a, b| delta_e2000(a.0, lab).total_cmp(&delta_e2000(b.0, lab))).map(|(_, p)| p)
};
let links = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let Some(Paint::Solid { color, swatch, .. }) = to(*c) else { return false };
if *c == *color && *link == *swatch {
return false;
}
(*c, *link, *tint) = (*color, swatch.clone(), 1.0);
true
};
let given = json!({
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false,
});
let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?;
squash(s, undo, "Apply Nearest Swatch");
Ok(json!({ "changed": r["changed"], "swatches": names, "added": added }))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,115 @@
//! Find Nearest Swatch.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::NodeId;
use crate::Session;
const FIND: &str = "addon.color.nearestSwatch";
const APPLY: &str = "addon.color.applyNearestSwatch";
/// A document with a global swatch "Brand" and a selected rectangle filled `fill`, stroked `stroke`.
fn doc(fill: &str, stroke: Option<&str>) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff8000", "global": true})).unwrap();
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color": fill})).unwrap();
match stroke {
Some(c) => s.execute("paint.setStroke", &json!({"color": c})).unwrap(),
None => s.execute("paint.setStroke", &json!({"none": true})).unwrap(),
};
(s, id)
}
fn paints(s: &Session, id: NodeId) -> (Paint, Paint) {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
(n.appearance.fill_paint(), n.appearance.items.iter().find(|i| !i.is_fill()).unwrap().paint().clone())
}
fn names(matches: &Value) -> Vec<&str> {
matches.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap()).collect()
}
#[test]
fn the_closest_swatches_of_a_library_are_listed_closest_first() {
let (mut s, _) = doc("#ff6634", None);
let v = s.execute(FIND, &json!({"library": "web-safe-216", "count": 3})).unwrap();
assert_eq!(v["library"], "web-safe-216");
let colors = v["colors"].as_array().unwrap();
assert_eq!(colors.len(), 1, "the fill; no stroke");
assert_eq!((colors[0]["hex"].as_str(), colors[0]["values"].as_str()), (Some("#ff6634"), Some("R=255 G=102 B=52")));
let m = &colors[0]["matches"];
assert_eq!(names(m)[0], "#FF6633");
let de: Vec<f64> = m.as_array().unwrap().iter().map(|x| x["deltaE"].as_f64().unwrap()).collect();
assert_eq!(de.len(), 3);
assert!(de[0] < 1.0 && de.windows(2).all(|w| w[0] <= w[1]), "{de:?}");
// A colour given is looked up instead of the selection's.
let v = s.execute(FIND, &json!({"library": "web-safe-216", "color": "#010203", "count": 1})).unwrap();
assert_eq!(names(&v["colors"][0]["matches"]), ["#000000"]);
}
#[test]
fn the_document_swatches_are_the_default_library() {
let (mut s, _) = doc("#ff8010", Some("#ff8010"));
let v = s.execute(FIND, &json!({})).unwrap();
assert_eq!(v["library"], "document");
assert_eq!(v["colors"].as_array().unwrap().len(), 1, "fill and stroke share a colour: looked up once");
assert_eq!(names(&v["colors"][0]["matches"])[0], "Brand");
assert!(!names(&v["colors"][0]["matches"]).contains(&"[Registration]"));
}
#[test]
fn applying_links_each_colour_to_its_nearest_document_swatch_as_one_undo_step() {
let (mut s, id) = doc("#ff8010", Some("#ff8010"));
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute(APPLY, &json!({"stroke": false})).unwrap();
assert_eq!((v["changed"].as_u64(), v["swatches"].clone(), v["added"].clone()), (Some(1), json!(["Brand"]), json!([])));
let (fill, stroke) = paints(&s, id);
assert!(matches!(&fill, Paint::Solid { swatch: Some(n), color, .. } if n == "Brand" && color.to_hex() == "#ff8000"), "{fill:?}");
assert_eq!(stroke.color().unwrap().to_hex(), "#ff8010", "strokes left alone");
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Apply Nearest Swatch"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paints(&s, id).0.color().unwrap().to_hex(), "#ff8010");
}
#[test]
fn a_library_spot_colour_joins_the_document_and_stays_spot() {
let (mut s, id) = doc("#e01525", None);
// A colour book of one spot colour, loaded as a user would load one they own.
s.execute("swatch.new", &json!({"name": "Ink Red", "color": "#e01020", "spot": true})).unwrap();
let data = s.execute("swatch.library.save", &json!({"names": ["Ink Red"]})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("swatch.delete", &json!({"names": ["Ink Red"]})).unwrap();
let lib = s.execute("swatch.library.load", &json!({"data": data, "name": "Book.vcswatches"})).unwrap()["library"].as_str().unwrap().to_string();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute(APPLY, &json!({"library": lib})).unwrap();
assert_eq!((v["swatches"].clone(), v["added"].clone()), (json!(["Ink Red"]), json!(["Ink Red"])));
let d = &s.doc().unwrap().doc;
assert!(d.swatch("Ink Red").is_some_and(|w| w.spot && w.global));
assert!(matches!(paints(&s, id).0, Paint::Solid { swatch: Some(n), .. } if n == "Ink Red"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "adding and applying undo together");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.swatch("Ink Red").is_none());
assert_eq!(paints(&s, id).0, Paint::solid(Color::from_hex("#e01525").unwrap()));
}
#[test]
fn bad_requests_are_refused() {
let (mut s, _) = doc("#ff8010", None);
for (p, why) in [
(json!({"library": "nope"}), "no swatch library `nope`"),
(json!({"library": "spectrum-gradients"}), "has no colours"),
(json!({"count": 0}), "`count` must be from 1 to 50"),
(json!({"color": "zzz"}), "`color` is not a colour"),
(json!({"fill": "yes"}), "`fill` must be true or false"),
] {
let e = s.execute(FIND, &p).unwrap_err().to_string();
assert!(e.contains(why), "{p}: {e}");
}
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute(FIND, &json!({})).unwrap_err().to_string().contains("select objects or give `color`"));
assert!(s.execute(APPLY, &json!({})).is_err(), "nothing selected");
}