mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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