diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index d9a8b487a..ac6553615 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -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 { "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 { "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, } -/// 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 { +/// 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>) -> Vec { + fn read<'a>(n: &'a Node, only: Option<&BTreeSet>, 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 = 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 { }; // 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.into_iter().map(|(_, u)| u).collect() } -fn colors(s: &mut Session, _: &Value) -> Result { +/// 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, Option>)> { let st = s.doc()?; - let v: Vec = 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 { + let (ids, only) = scope_ids(s, p, "recolor.colors")?; + let st = s.doc()?; + let v: Vec = 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 { 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, Vec) = used_colors(&st.doc, &st.selection.objects).into_iter().partition(|u| preserve.keeps(&u.color)); + let (kept, used): (Vec, Vec) = 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![]; for u in &used { diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index efea2f176..e2b7c2a92 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -200,7 +200,7 @@ pub fn specs() -> Vec { /// 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) -> Vec { +pub(crate) fn selectable(d: &Document, iso: Option) -> Vec { 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. diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests_recolor.rs index bca05af5c..47b8ce45b 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests_recolor.rs @@ -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::>(); + 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::>(); + 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::>(); + 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(); diff --git a/docs/mcp.md b/docs/mcp.md index e96973648..a0ef6e4b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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