Recolor: recolor.colors and recolor.reduce read the art Select All takes when nothing is selected

recolor.colors and recolor.reduce were unavailable with nothing selected, so
listing a document's colors needed a select.all first, which also changed the
selection. Both now take ids and read the selection when ids is left out.
With nothing selected they read the objects Select All takes and the objects
inside them: hidden and locked objects are left out with what they hold, also
inside a group, as are the objects of hidden, locked and template layers, and
in isolation mode only the isolated art is read. The selection is left
unchanged. An object named in ids or selected is read with everything it
holds, and an object listed twice, or listed together with a group that holds
it, counts once. recolor.apply still recolors only the selection.
This commit is contained in:
dotCooCoo
2026-10-24 17:29:32 +07:00
committed by bflatastic
parent 500c84e2c7
commit 7da8edab79
4 changed files with 157 additions and 33 deletions
+61 -31
View File
@@ -1,15 +1,16 @@
//! Edit → Edit Colors → Recolor Artwork: list the selection's colours, reduce them to rows of
//! Edit → Edit Colors → Recolor Artwork: list the colors of the selection (or of the art Select All
//! takes when nothing is selected), reduce them to rows of
//! similar colours, and remap each row to a new colour by a recolour method. Colours are keyed by
//! their identity ([`ColorKey`]: model and exact values), so a CMYK colour stays CMYK and two
//! colours that only look alike stay apart.
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use serde_json::{Value, json};
use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::node_colors;
use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind};
use vectorcraft_doc::swatches::{node_colors, node_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::colorcmds::{Recolor, Scope};
use super::*;
@@ -21,8 +22,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Artwork Colors",
[],
None,
"{} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} unique colours used by the selection (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first",
has_selection,
"{ids?: object ids (default: the selection, or with nothing selected the objects select.all takes, without the hidden and locked objects inside them)} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} unique colours used by the objects (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first",
has_doc,
colors
),
cmd!(
@@ -39,8 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce Colors",
[],
None,
"{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply; default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}",
has_selection,
"{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply; default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it), ids?: as recolor.colors} group the colors of the objects (default: as recolor.colors) into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}",
has_doc,
reduce
),
cmd!(
@@ -62,9 +63,19 @@ struct Used {
swatch: Option<String>,
}
/// The unique colours of the selection, most used first (ties by key): fills, strokes, gradient
/// stops, text, mesh points and the tiles of the patterns used as fills or strokes.
fn used_colors(d: &Document, ids: &[NodeId]) -> Vec<Used> {
/// The unique colours of the objects `ids` and the objects inside them, most used first (ties by
/// key): fills, strokes, gradient stops, text, mesh points and the tiles of the patterns used as
/// fills or strokes. With `only`, an object not in it is left out with everything it holds.
fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet<NodeId>>) -> Vec<Used> {
fn read<'a>(n: &'a Node, only: Option<&BTreeSet<NodeId>>, out: &mut Vec<&'a Node>) {
if only.is_some_and(|o| !o.contains(&n.id)) {
return;
}
out.push(n);
for c in n.children().into_iter().flatten() {
read(c, only, out);
}
}
let mut index: HashMap<ColorKey, usize> = HashMap::new();
let mut used: Vec<(ColorKey, Used)> = vec![];
let mut count = |c: &Color, link: Option<&str>| {
@@ -81,25 +92,22 @@ fn used_colors(d: &Document, ids: &[NodeId]) -> Vec<Used> {
};
// The patterns used as fills or strokes: their tiles count once each.
let mut patterns: Vec<&str> = vec![];
let mut nodes = vec![];
for n in ids.iter().filter_map(|id| d.node(*id)) {
node_colors(n, &mut count);
n.walk(&mut |m| {
let runs = match &m.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
};
let items = m.appearance.items.iter().map(|it| match it {
AppearanceItem::Fill(l) => &l.paint,
AppearanceItem::Stroke(l) => &l.paint,
});
for p in items.chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) {
if let Paint::Pattern { pattern, .. } = p
&& !patterns.contains(&pattern.as_str())
{
patterns.push(pattern);
}
read(n, only, &mut nodes);
}
for m in nodes {
for p in node_paints(m) {
match p {
Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()),
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())),
Paint::Pattern { pattern, .. } if !patterns.contains(&pattern.as_str()) => patterns.push(pattern),
_ => {}
}
});
}
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| count(&p.color, None));
}
}
for def in patterns.iter().filter_map(|p| d.pattern(p)) {
def.art.iter().for_each(|n| node_colors(n, &mut count));
@@ -108,9 +116,30 @@ fn used_colors(d: &Document, ids: &[NodeId]) -> Vec<Used> {
used.into_iter().map(|(_, u)| u).collect()
}
fn colors(s: &mut Session, _: &Value) -> Result<Value> {
/// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids`
/// (each checked) or else the selection, with everything they hold; with nothing selected, the
/// objects Select All takes and the objects inside them, leaving out every hidden or locked object
/// with what it holds and the objects of hidden, locked and template layers (the second value
/// lists the objects read). Objects listed twice or inside another one listed are dropped, so
/// their colors count once.
fn scope_ids(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<NodeId>, Option<BTreeSet<NodeId>>)> {
let st = s.doc()?;
let v: Vec<Value> = used_colors(&st.doc, &st.selection.objects)
let ids = match p.get("ids") {
Some(_) => checked_ids_param(s, p, "ids", cmd)?,
None if st.selection.is_empty() => {
let only = super::select::selectable_ids(&st.doc, st.isolation);
return Ok((super::select::selectable(&st.doc, st.isolation), Some(only)));
}
None => st.selection.objects.clone(),
};
let mut seen = BTreeSet::new();
Ok((super::edit::outermost(&st.doc, ids).into_iter().filter(|id| seen.insert(*id)).collect(), None))
}
fn colors(s: &mut Session, p: &Value) -> Result<Value> {
let (ids, only) = scope_ids(s, p, "recolor.colors")?;
let st = s.doc()?;
let v: Vec<Value> = used_colors(&st.doc, &ids, only.as_ref())
.iter()
.map(|u| {
let mut v = json!({"key": key_str(&u.color), "hex": u.color.to_hex(), "count": u.count});
@@ -145,8 +174,9 @@ fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
let def = Preserve::default();
let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d));
let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) };
let (ids, only) = scope_ids(s, p, C)?;
let st = s.doc()?;
let (kept, used): (Vec<Used>, Vec<Used>) = used_colors(&st.doc, &st.selection.objects).into_iter().partition(|u| preserve.keeps(&u.color));
let (kept, used): (Vec<Used>, Vec<Used>) = used_colors(&st.doc, &ids, only.as_ref()).into_iter().partition(|u| preserve.keeps(&u.color));
// Units that stay together: the tints of a global swatch, else one colour each.
let mut units: Vec<Vec<&Used>> = vec![];
for u in &used {
+1 -1
View File
@@ -200,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
/// The objects Select All takes: those of the isolated container, else of every layer (looking
/// through sublayers, which are not objects). In isolation mode there are none while the isolated
/// container, or a group or layer it is in, is hidden, locked or a template layer.
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
pub(crate) fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
match iso.and_then(|i| d.node(i)) {
Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => {
// A single object isolated is all there is to select.
+91
View File
@@ -76,6 +76,97 @@ fn five_colours_reduce_to_two() {
assert_eq!(run(&mut s, "recolor.reduce", json!({"colors": (1u64 << 32) + 2}))["map"].as_array().unwrap().len(), 5);
}
#[test]
fn colors_with_nothing_selected_come_from_all_the_art() {
let mut s = session();
let red = rect(&mut s, json!("#ff0000"));
let blue = rect(&mut s, json!("#0000ff"));
run(&mut s, "select.set", json!({"ids": []}));
let hexes = |v: &Value| v["colors"].as_array().unwrap().iter().map(|c| c["hex"].as_str().unwrap().to_string()).collect::<Vec<_>>();
let mut all = hexes(&run(&mut s, "recolor.colors", json!({})));
all.sort();
assert_eq!(all, ["#0000ff", "#ff0000"]);
assert!(s.doc().unwrap().selection.objects.is_empty(), "the selection is left as it was");
assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"].as_array().unwrap().len(), 2);
// `ids` names the objects; otherwise the selection does.
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({"ids": [blue.0]}))), ["#0000ff"]);
// An id listed twice counts once.
let once = run(&mut s, "recolor.colors", json!({"ids": [red.0]}))["colors"].clone();
assert_eq!(run(&mut s, "recolor.colors", json!({"ids": [red.0, red.0]}))["colors"], once);
assert_eq!(run(&mut s, "recolor.reduce", json!({"ids": [red.0]}))["map"][0]["from"], json!(["rgb 255 0 0"]));
run(&mut s, "select.set", json!({"ids": [red.0]}));
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), ["#ff0000"]);
let e = s.execute("recolor.colors", &json!({"ids": [9999]})).unwrap_err().to_string();
assert!(e.contains("no such object id 9999"), "{e}");
// Recolor Artwork still recolors only what is selected.
run(&mut s, "select.set", json!({"ids": []}));
assert!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}})).is_err());
assert_eq!(fill(&s, red).to_hex(), "#ff0000");
}
#[test]
fn colors_with_nothing_selected_leave_out_what_select_all_leaves_out() {
let mut s = session();
rect(&mut s, json!("#ff0000"));
let hidden = rect(&mut s, json!("#0000ff"));
let locked = rect(&mut s, json!("#00ff00"));
run(&mut s, "object.hide", json!({"ids": [hidden.0]}));
run(&mut s, "object.lock", json!({"ids": [locked.0]}));
// A hidden layer and a locked layer, each holding a rectangle.
for (fill, props) in [("#ffff00", json!({"visible": false})), ("#ff00ff", json!({"locked": true}))] {
let layer = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
let id = run(&mut s, "shape.rectangle", json!({"x": 300, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap();
run(&mut s, "paint.setFill", json!({"ids": [id], "color": fill}));
assert_eq!(s.doc().unwrap().doc.layer_containing(NodeId(id)), Some(NodeId(layer)));
let mut p = props;
p["id"] = json!(layer);
run(&mut s, "layer.setProps", p);
}
let hexes = |v: &Value| v["colors"].as_array().unwrap().iter().map(|c| c["hex"].as_str().unwrap().to_string()).collect::<Vec<_>>();
run(&mut s, "select.set", json!({"ids": []}));
let none = hexes(&run(&mut s, "recolor.colors", json!({})));
assert_eq!(none, ["#ff0000"]);
assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"], json!([{"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}]));
select_all(&mut s);
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), none, "the same colors as after Select All");
// `ids` still reads hidden and locked objects.
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({"ids": [hidden.0]}))), ["#0000ff"]);
}
#[test]
fn colors_with_nothing_selected_leave_out_hidden_and_locked_objects_in_groups() {
let mut s = session();
rect(&mut s, json!("#ff0000"));
let hidden = rect(&mut s, json!("#0000ff"));
let locked = rect(&mut s, json!("#00ff00"));
// A group inside the group, holding a hidden rectangle.
let inner_hidden = rect(&mut s, json!("#ffff00"));
run(&mut s, "select.set", json!({"ids": [inner_hidden.0]}));
run(&mut s, "object.group", json!({}));
select_all(&mut s);
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1, "one group holds the art");
run(&mut s, "object.hide", json!({"ids": [hidden.0, inner_hidden.0]}));
run(&mut s, "object.lock", json!({"ids": [locked.0]}));
let hexes = |v: &Value| {
let mut h = v["colors"].as_array().unwrap().iter().map(|c| c["hex"].as_str().unwrap().to_string()).collect::<Vec<_>>();
h.sort();
h
};
run(&mut s, "select.set", json!({"ids": []}));
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), ["#ff0000"]);
assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"], json!([{"from": ["rgb 255 0 0"], "to": "rgb 255 0 0"}]));
// Isolation mode reads the same objects of the group.
run(&mut s, "object.isolate", json!({"id": g.0}));
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), ["#ff0000"]);
run(&mut s, "object.exitIsolation", json!({}));
// The group named in `ids`, or selected, is read with everything it holds.
let all = ["#0000ff", "#00ff00", "#ff0000", "#ffff00"];
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({"ids": [g.0]}))), all);
run(&mut s, "select.set", json!({"ids": [g.0]}));
assert_eq!(hexes(&run(&mut s, "recolor.colors", json!({}))), all);
}
#[test]
fn preserve_flags_leave_neutrals_out() {
let mut s = session();
+4 -1
View File
@@ -1655,7 +1655,10 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh
Recolor Artwork keys colours by identity, not hex: `recolor.colors` lists each colour's `key` (`"rgb 255 0 0"`,
`"cmyk 0 100 100 0"`, `"gray 40"`, `"lab 55 60 -40"`; RGB in 0–255 levels, CMYK and Gray in percent), so CMYK,
Lab and RGB colours that look alike stay apart, and every colour parameter also accepts a key.
`recolor.reduce {colors?, method?, preserve?, limitTo?}` groups the selection's colours into rows of similar colours (k-means in Lab, weighted by use;
`recolor.colors` and `recolor.reduce` read the objects in `ids`, else the selection. With nothing selected they read
the objects `select.all` takes, which leaves out hidden and locked art, and the selection is left unchanged; hidden
and locked objects inside a group are left out too, also in isolation mode.
`recolor.reduce {colors?, method?, preserve?, limitTo?, ids?}` groups those colours into rows of similar colours (k-means in Lab, weighted by use;
tints of a global swatch share its row; White and Black are preserved by default) and returns a map of rows
`[{from: [keys], to: key}]` to edit and pass to `recolor.apply {map, method?, model?, limitTo?, group?, groupColors?, rename?}`.
For scripts and MCP, `model` selects the replacement color model: `keep` (default) retains each