mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Recolor Artwork: library colours apply as their swatches; import a library
With Limit to Library, a row's new colour is the library swatch itself: art links to a global or spot colour (a Pantone-style spot colour stays a spot colour in its own model, tints keep their tint) and a process swatch gives its colour in its own model, instead of being converted to the model of the colour it replaces. The swatches used join the document's swatches (one the document already has, same name and colour, is reused); recolor.apply reports them as `swatches`. A folder button next to Limit to Library loads a library file (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limits to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
1ba448f7f3
commit
5252c3d86a
@@ -282,6 +282,23 @@ impl RowMap {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// The tint (0..1) of the new colour current colour `c` takes, when it is a tint of the new
|
||||
/// colour: what a colour linked to a global or spot swatch keeps as its tint. `None` when the
|
||||
/// method makes it something else (a shade, or another hue).
|
||||
pub fn tint(&self, c: Color) -> Option<f32> {
|
||||
match self.method {
|
||||
Method::Exact => Some(1.0),
|
||||
Method::PreserveTints if is_tint(c, self.key) => Some(tint_of(c, self.key)),
|
||||
Method::PreserveTints => Some(1.0),
|
||||
Method::ScaleTints => Some(tint_of(c, self.key)),
|
||||
Method::TintsShades => {
|
||||
let d = c.to_lab().l - self.mean_l;
|
||||
(d >= 0.0).then(|| 1.0 - d / (100.0 - self.mean_l).max(1e-3))
|
||||
}
|
||||
Method::HueShift => (c == self.key).then_some(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The new colour of current colour `c`, in `c`'s model.
|
||||
pub fn map(&self, c: Color) -> Color {
|
||||
let to = keep_model(c, self.to);
|
||||
|
||||
@@ -204,6 +204,12 @@ impl<'a> Recolor<'a> {
|
||||
Self { links: Some(links), ..Self::new(Scope { images: false, ..scope }, &same) }
|
||||
}
|
||||
|
||||
/// A pass that maps solid colours and gradient stops with their swatch link and tint
|
||||
/// (`links`), and meshes, images and patterns through `f`.
|
||||
pub(crate) fn with_links(scope: Scope, f: &'a dyn Fn(Color) -> Color, links: &'a LinkMap<'a>) -> Self {
|
||||
Self { links: Some(links), ..Self::new(scope, f) }
|
||||
}
|
||||
|
||||
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
|
||||
/// pattern swatches, and return the number of paints, meshes and images changed.
|
||||
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::swatches::node_colors;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind};
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Recolor Artwork",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces) and the group, as one undo step → {changed}",
|
||||
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces, except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches]}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -252,21 +252,34 @@ fn parse_rows(p: &Value, cmd: &str) -> Result<Vec<Row>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed map ready to recolour: the row of each current colour.
|
||||
/// The document swatch a row's new colour is: its name, whether it is global (art links to it,
|
||||
/// with tints) and the colour art linked to it shows (a process colour's own colour).
|
||||
#[derive(Clone)]
|
||||
struct Link {
|
||||
name: String,
|
||||
global: bool,
|
||||
color: Color,
|
||||
}
|
||||
|
||||
/// A parsed map ready to recolour: the row of each current colour, and the swatch of each row's
|
||||
/// new colour.
|
||||
struct ColorMap {
|
||||
rows: Vec<RowMap>,
|
||||
links: Vec<Option<Link>>,
|
||||
keys: HashMap<ColorKey, usize>,
|
||||
hexes: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ColorMap {
|
||||
fn new(rows: &[Row], method: Method) -> Self {
|
||||
let mut out = Self { rows: vec![], keys: HashMap::new(), hexes: HashMap::new() };
|
||||
for r in rows.iter().filter(|r| !r.from.is_empty()) {
|
||||
/// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none).
|
||||
fn new(rows: &[Row], method: Method, links: &[Option<Link>]) -> Self {
|
||||
let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new() };
|
||||
for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) {
|
||||
let Some(to) = r.new_color() else { continue };
|
||||
let i = out.rows.len();
|
||||
let colors: Vec<Color> = r.from.iter().map(Match::color).collect();
|
||||
out.rows.push(RowMap::new(method, &colors, to));
|
||||
out.links.push(links.get(n).cloned().flatten());
|
||||
for m in &r.from {
|
||||
match m {
|
||||
Match::Key(k) => out.keys.entry(*k).or_insert(i),
|
||||
@@ -277,9 +290,39 @@ impl ColorMap {
|
||||
out
|
||||
}
|
||||
|
||||
/// The row of current colour `c`.
|
||||
fn row_of(&self, c: Color) -> Option<usize> {
|
||||
self.keys.get(&ColorKey::of(&c)).or_else(|| if self.hexes.is_empty() { None } else { self.hexes.get(&c.to_hex()) }).copied()
|
||||
}
|
||||
|
||||
fn map(&self, c: Color) -> Color {
|
||||
let row = self.keys.get(&ColorKey::of(&c)).or_else(|| if self.hexes.is_empty() { None } else { self.hexes.get(&c.to_hex()) });
|
||||
row.map_or(c, |i| self.rows[*i].map(c))
|
||||
self.row_of(c).and_then(|i| self.rows.get(i)).map_or(c, |r| r.map(c))
|
||||
}
|
||||
|
||||
/// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot
|
||||
/// swatch links its colours to it (at their tints; shades and other hues stay unlinked), a
|
||||
/// process swatch gives its colour in its own model; other rows map the colour, which loses its
|
||||
/// link when it changes. True when anything changed.
|
||||
fn map_link(&self, c: &mut Color, link: &mut Option<String>, tint: &mut f32) -> bool {
|
||||
let Some(i) = self.row_of(*c) else { return false };
|
||||
let (Some(row), Some(l)) = (self.rows.get(i), self.links.get(i)) else { return false };
|
||||
let new = match (l, row.tint(*c)) {
|
||||
(Some(l), Some(t)) if l.global => {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let shown = l.color.tinted(t);
|
||||
let changed = link.as_deref() != Some(l.name.as_str()) || *tint != t || *c != shown;
|
||||
(*c, *link, *tint) = (shown, Some(l.name.clone()), t);
|
||||
return changed;
|
||||
}
|
||||
(Some(l), Some(t)) => l.color.tinted(t),
|
||||
_ => row.map(*c),
|
||||
};
|
||||
if new == *c {
|
||||
return false;
|
||||
}
|
||||
*c = new;
|
||||
*link = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,13 +336,19 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
r.to = r.to.map(|c| pl.nearest(c));
|
||||
}
|
||||
}
|
||||
// With Limit to Library, a row's new colour is a swatch of the library: the art takes that
|
||||
// swatch (a spot colour stays a spot colour), which joins the document's swatches.
|
||||
let lib_swatches = match str_param(p, "limitTo").filter(|k| !k.is_empty()) {
|
||||
Some(k) => super::swatchlib::library_swatches(s, k).unwrap_or_default(),
|
||||
None => vec![],
|
||||
};
|
||||
let row_swatches: Vec<Option<Swatch>> =
|
||||
rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect();
|
||||
let ids = s.doc()?.selection.objects.clone();
|
||||
let group = str_param(p, "group");
|
||||
if ids.is_empty() && group.is_none() {
|
||||
return Err(bad(C, "select artwork to recolour, or give a colour `group`"));
|
||||
}
|
||||
let map = ColorMap::new(&rows, method);
|
||||
let f = |c: Color| map.map(c);
|
||||
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
|
||||
let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" };
|
||||
let new: Vec<Color> = match p.get("groupColors").and_then(Value::as_array) {
|
||||
@@ -312,15 +361,38 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => rows.iter().filter_map(Row::new_color).collect(),
|
||||
};
|
||||
let rename = str_param(p, "rename");
|
||||
let (changed, edit) = s.edit(label, |d, _| {
|
||||
let changed = Recolor::new(scope, &f).run(d, &ids);
|
||||
let (changed, edit, added) = s.edit(label, |d, _| {
|
||||
// The rows' library swatches join the document (one it has with that name and colour is
|
||||
// used as it is).
|
||||
let mut added = vec![];
|
||||
let links: Vec<Option<Link>> = row_swatches
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let w = w.as_ref()?;
|
||||
let name = match d.swatch(&w.name) {
|
||||
Some(x) if x.paint == w.paint => w.name.clone(),
|
||||
_ => {
|
||||
let name = d.free_swatch_name(&w.name);
|
||||
d.swatches.push(Swatch { name: name.clone(), ..w.clone() });
|
||||
added.push(name.clone());
|
||||
name
|
||||
}
|
||||
};
|
||||
let color = d.global_color(&name).filter(|_| w.global).or(w.paint.color())?;
|
||||
Some(Link { name, global: w.global, color })
|
||||
})
|
||||
.collect();
|
||||
let map = ColorMap::new(&rows, method, &links);
|
||||
let f = |c: Color| map.map(c);
|
||||
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
|
||||
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
|
||||
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
|
||||
Ok((changed, edit))
|
||||
Ok((changed, edit, added))
|
||||
})?;
|
||||
if let Some(e) = edit {
|
||||
e.defaults(s);
|
||||
}
|
||||
Ok(json!({ "changed": changed }))
|
||||
Ok(json!({ "changed": changed, "swatches": added }))
|
||||
}
|
||||
|
||||
fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -393,6 +393,17 @@ pub fn library_colors(s: &Session, key: &str) -> Option<Vec<Color>> {
|
||||
Some(library(s, key)?.1.iter().filter_map(colors).collect())
|
||||
}
|
||||
|
||||
/// The colour swatches of library `key` (an id or name) or, for [`DOCUMENT_SWATCHES`], of the
|
||||
/// active document (tint swatches left out: they stand for their base). `None` when there is no
|
||||
/// such library (or document).
|
||||
pub fn library_swatches(s: &Session, key: &str) -> Option<Vec<Swatch>> {
|
||||
let own = |sw: &&Swatch| matches!(sw.paint, Paint::Solid { swatch: None, .. });
|
||||
if key == DOCUMENT_SWATCHES {
|
||||
return Some(s.active()?.doc.swatches_iter().filter(own).cloned().collect());
|
||||
}
|
||||
Some(library(s, key)?.1.iter().filter(own).cloned().collect())
|
||||
}
|
||||
|
||||
/// The `limitTo` parameter's palette ([`limit_palette`]); `None` without one (or for "").
|
||||
pub(crate) fn limit_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Palette>> {
|
||||
let Some(key) = str_param(p, "limitTo").filter(|k| !k.is_empty()) else { return Ok(None) };
|
||||
|
||||
@@ -316,3 +316,54 @@ fn cmyk_object_reads_one_scale_for_every_component() {
|
||||
assert!(close(k(json!({"c": 0.1, "m": 0.5, "y": 0.9, "k": 0.2})), [0.1, 0.5, 0.9, 0.2]));
|
||||
assert!(close(k(json!({"c": 0, "m": 1, "y": 1, "k": 0})), [0.0, 1.0, 1.0, 0.0]));
|
||||
}
|
||||
|
||||
/// Recolouring to a library's spot colour (Limit to Library) applies the spot colour itself, linked
|
||||
/// to it (tints keep their tint), and adds it to the document's swatches; a process swatch of the
|
||||
/// library gives its colour in its own model.
|
||||
#[test]
|
||||
fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() {
|
||||
let mut s = session();
|
||||
// A library with a Lab spot colour and a CMYK process colour (made here, then removed).
|
||||
run(&mut s, "swatch.new", json!({"name": "Ink 101", "color": {"l": 50.0, "a": 70.0, "b": 40.0}, "spot": true}));
|
||||
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": {"c": 0.6, "m": 0.0, "y": 1.0, "k": 0.1}}));
|
||||
let data = run(&mut s, "swatch.library.save", json!({"names": ["Ink 101", "Leaf"], "name": "Inks"}))["data"].as_str().unwrap().to_string();
|
||||
let lib = run(&mut s, "swatch.library.load", json!({"data": data, "name": "inks.vcswatches"}))["library"].as_str().unwrap().to_string();
|
||||
run(&mut s, "swatch.delete", json!({"names": ["Ink 101", "Leaf"]}));
|
||||
let red = rect(&mut s, json!("#e02010"));
|
||||
let pink = rect(&mut s, json!("#f09088"));
|
||||
let green = rect(&mut s, json!("#20c040"));
|
||||
select_all(&mut s);
|
||||
let map = json!([
|
||||
{"from": ["rgb 224 32 16", "rgb 240 144 136"], "to": "#e02010"},
|
||||
{"from": ["rgb 32 192 64"], "to": "#20c040"},
|
||||
]);
|
||||
let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "limitTo": lib}));
|
||||
assert_eq!(r["swatches"], json!(["Ink 101", "Leaf"]), "{r}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let ink = d.swatch("Ink 101").unwrap();
|
||||
assert!(ink.spot && ink.global, "the spot colour joined the document's swatches");
|
||||
let paint = |id: NodeId| d.node(id).unwrap().appearance.fill_paint();
|
||||
match paint(red) {
|
||||
vectorcraft_color::Paint::Solid { color, swatch, tint } => {
|
||||
assert_eq!(swatch.as_deref(), Some("Ink 101"), "linked to the spot colour");
|
||||
assert_eq!(tint, 1.0);
|
||||
assert_eq!(color, Color::lab(50.0, 70.0, 40.0), "in the spot colour's own model");
|
||||
}
|
||||
p => panic!("{p:?}"),
|
||||
}
|
||||
match paint(pink) {
|
||||
vectorcraft_color::Paint::Solid { swatch, tint, .. } => {
|
||||
assert_eq!(swatch.as_deref(), Some("Ink 101"));
|
||||
assert!(tint > 0.1 && tint < 0.9, "a tint of the spot colour: {tint}");
|
||||
}
|
||||
p => panic!("{p:?}"),
|
||||
}
|
||||
assert_eq!(paint(green), vectorcraft_color::Paint::solid(Color::cmyk(0.6, 0.0, 1.0, 0.1)), "a process swatch's colour, in CMYK");
|
||||
// Again: the document's swatch is used, not added twice.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
select_all(&mut s);
|
||||
run(&mut s, "swatch.library.add", json!({"library": lib, "names": ["Ink 101"]}));
|
||||
select_all(&mut s);
|
||||
let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "limitTo": lib}));
|
||||
assert_eq!(r["swatches"], json!(["Leaf"]));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! Assign tab: Colors (Auto, or a count: similar colours merge into rows), Preserve White, Black and
|
||||
//! Grays, the rows (click to select, Shift or Cmd/Ctrl-click to add; a row's arrow excludes or
|
||||
//! includes it; the hex field edits its new colour), Merge and Separate, Randomize order or
|
||||
//! saturation and brightness, Reset, the Method and Limit to Library. Edit tab: a hue and
|
||||
//! saturation and brightness, Reset, the Method and Limit to Library (its folder button imports a
|
||||
//! library file and limits to it; a library's colours apply as its swatches: a spot colour stays a
|
||||
//! spot colour, and joins the document's swatches). Edit tab: a hue and
|
||||
//! saturation wheel with a marker per new colour (drag one; with linked handles they all turn and
|
||||
//! scale together), a brightness slider and the harmony rules. The preset menu sets up a 1, 2 or 3
|
||||
//! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites
|
||||
@@ -443,9 +445,24 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut
|
||||
let cur = d.str("limitTo");
|
||||
let at = keys.iter().position(|k| *k == cur).unwrap_or(0);
|
||||
let current = shown.get(at).copied().unwrap_or_default();
|
||||
if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) {
|
||||
d.fields.insert("limitTo".into(), json!(k));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) {
|
||||
d.fields.insert("limitTo".into(), json!(k));
|
||||
}
|
||||
// Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it.
|
||||
if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() {
|
||||
let r = crate::picks::in_dialog(app, d, |app, d| {
|
||||
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() };
|
||||
let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) };
|
||||
let r = app.run("swatch.library.load", json!({ "path": path }))?;
|
||||
d.fields.insert("limitTo".into(), r["library"].clone());
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
changed
|
||||
|
||||
Reference in New Issue
Block a user