Merge pull request #3947 from dotCooCoo/fix/recolor-colors-without-selection

Recolor: recolor.colors and recolor.reduce read the art Select All takes when nothing is selected
This commit is contained in:
BFlat
2026-10-24 17:53:16 +07:00
committed by GitHub
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