mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user