Merge pull request #5214 from dotCooCoo/feat/recolor-global-adjust

Recolor: the Global Adjust sliders change every color at once
This commit is contained in:
Brandon Thomas
2026-11-02 00:34:59 -05:00
committed by GitHub
34 changed files with 657 additions and 44 deletions
+56 -15
View File
@@ -7,7 +7,7 @@
use std::collections::{BTreeSet, HashMap};
use serde_json::{Value, json};
use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster};
use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::{node_colors, node_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}",
has_doc,
apply
),
@@ -224,6 +224,30 @@ fn reduce(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"map": map, "preserved": kept.iter().map(|u| key_str(&u.color)).collect::<Vec<_>>(), "method": method.id()}))
}
/// The `globalAdjust` parameter: `{saturation?, brightness?, temperature?, luminosity?}`, each a
/// percentage from -100 to 100 (0 when left out or null), as amounts from -1 to 1.
fn adjust_param(p: &Value, cmd: &str) -> Result<GlobalAdjust> {
let given = match p.get("globalAdjust") {
None | Some(Value::Null) => return Ok(GlobalAdjust::default()),
Some(v @ Value::Object(_)) => v,
Some(_) => return Err(bad(cmd, "`globalAdjust` must be an object")),
};
let amount = |key: &str| -> Result<f32> {
let x = super::number_or(given, key, 0.0, cmd)?;
let (min, max) = (-100, 100);
if !(f64::from(min)..=f64::from(max)).contains(&x) {
return Err(bad(cmd, format!("`{key}` must be between {min} and {max} (got {x})")));
}
Ok((x / 100.0) as f32)
};
Ok(GlobalAdjust {
saturation: amount("saturation")?,
brightness: amount("brightness")?,
temperature: amount("temperature")?,
luminosity: amount("luminosity")?,
})
}
/// A current colour of a row: an exact colour, or every colour shown as a hex.
enum Match {
Key(ColorKey),
@@ -370,15 +394,25 @@ struct ColorMap {
hexes: HashMap<String, usize>,
/// Set when a colour took a new colour of another model in its own model.
converted: std::cell::Cell<bool>,
/// The Global Adjust every mapped colour takes after its row's new colour.
adjust: GlobalAdjust,
}
impl ColorMap {
/// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none); `group`: the
/// global swatches of the color group being rewritten; `model`: the model rule of a row without
/// its own.
fn new(rows: &[Row], method: Method, model: ModelRule, links: &[Option<Link>], group: Vec<GroupSwatch>) -> Self {
let mut out =
Self { rows: vec![], links: vec![], tos: vec![], group, keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() };
/// its own; `adjust`: the Global Adjust of each mapped colour.
fn new(rows: &[Row], method: Method, model: ModelRule, links: &[Option<Link>], group: Vec<GroupSwatch>, adjust: GlobalAdjust) -> Self {
let mut out = Self {
rows: vec![],
links: vec![],
tos: vec![],
group,
keys: HashMap::new(),
hexes: HashMap::new(),
converted: Default::default(),
adjust,
};
for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) {
let Some(to) = r.new_color() else { continue };
let i = out.rows.len();
@@ -387,7 +421,8 @@ impl ColorMap {
// Colors that can't link to the swatch take the row's tint of it too.
let mapped = link.as_ref().filter(|l| l.global && l.tint < 1.0).map_or(to, |l| to.tinted(l.tint));
out.rows.push(RowMap::new(method, &colors, mapped).with_own_model(r.model.unwrap_or(model) != ModelRule::Keep));
out.tos.push(to);
// Compared with the group's new colours, which take the adjustment too.
out.tos.push(adjust.apply(to));
out.links.push(link);
for m in &r.from {
match m {
@@ -409,7 +444,7 @@ impl ColorMap {
if r.converts(c) {
self.converted.set(true);
}
r.map(c)
self.adjust.apply(r.map(c))
}
/// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot
@@ -417,7 +452,8 @@ impl ColorMap {
/// stay unlinked), a
/// process swatch gives its colour in its own model; a color linked to a global swatch of the
/// rewritten group that takes the row's new color keeps its link (at its tint in the row) when
/// the row gives it the new color in that color's model;
/// the row gives it the new color in that color's model and the Global Adjust gives it the
/// swatch's new color at that tint;
/// other rows map the colour, which loses its link when it changes or when the group's rewrite
/// would change it. True when anything changed.
fn map_link(&self, c: &mut Color, link: &mut Option<String>, tint: &mut f32) -> bool {
@@ -427,9 +463,9 @@ impl ColorMap {
&& let Some(g) = link.as_deref().and_then(|n| self.group.iter().find(|g| g.name == n))
&& self.tos.get(i) == Some(&g.color)
&& !row.converts(*c)
&& let Some(t) = row.tint(*c)
&& let Some(t) = row.tint(*c).map(|t| t.clamp(0.0, 1.0))
&& (self.adjust.is_identity() || same_ink(self.adjust.apply(row.map(*c)), g.shown.tinted(t)))
{
let t = t.clamp(0.0, 1.0);
let shown = g.shown.tinted(t);
let changed = *tint != t || *c != shown;
(*c, *tint) = (shown, t);
@@ -448,7 +484,7 @@ impl ColorMap {
if row.converts(*c) {
self.converted.set(true);
}
row.map(*c)
self.adjust.apply(row.map(*c))
}
};
// A color its row leaves as it is still loses its link to a swatch of the rewritten group
@@ -468,9 +504,13 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let method = method_param(p, C, Method::Exact)?;
let palette = super::swatchlib::limit_param(s, p, C)?;
let mut rows = parse_rows(p, C)?;
let adjust = adjust_param(p, C)?;
// With Limit to Library a row's new colour takes the Global Adjust before it snaps, so the art
// still takes the library's colours; otherwise every colour the rows map takes it.
let mapped_adjust = if palette.is_some() { GlobalAdjust::default() } else { adjust };
if let Some(pl) = &palette {
for r in &mut rows {
r.to = r.to.map(|c| pl.nearest(c));
r.to = r.to.map(|c| pl.nearest(adjust.apply(c)));
}
}
// With Limit to Library, a row's new colour is a swatch of the library: the art takes that
@@ -510,11 +550,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|v| {
color_value(v)
.map(|c| adjust.apply(c))
.map(|c| in_doc_model(palette.as_ref().map_or(c, |pl| pl.nearest(c))))
.ok_or_else(|| bad(C, format!("bad colour {v} in groupColors")))
})
.collect::<Result<_>>()?,
None => rows.iter().filter_map(Row::new_color).collect(),
None => rows.iter().filter_map(Row::new_color).map(|c| mapped_adjust.apply(c)).collect(),
};
let rename = text_param(p, "rename", C)?;
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
@@ -553,7 +594,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
.collect()
})
.unwrap_or_default();
let map = ColorMap::new(&rows, method, model, &links, kept);
let map = ColorMap::new(&rows, method, model, &links, kept, mapped_adjust);
let f = |c: Color| map.map(c);
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
+143
View File
@@ -721,3 +721,146 @@ fn document_model_converts_the_colours_of_a_group_too() {
run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#00ff00"], "model": "document"}));
assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s));
}
fn stroke(s: &Session, id: NodeId) -> Color {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint().color().unwrap()
}
/// Global Adjust changes every colour the rows recolour, each one separately (the colours of a
/// row of several, fills and strokes alike), in its own model, as one undo step; an excluded
/// row's colours and preserved colours stay.
#[test]
fn global_adjust_changes_every_recolored_color() {
let mut s = session();
let red = rect(&mut s, json!("#ff0000"));
let pink = rect(&mut s, json!("#ff8080"));
let cmyk = rect(&mut s, json!({"c": 100, "m": 0, "y": 0, "k": 0}));
let kept = rect(&mut s, json!("#00ff00"));
run(&mut s, "paint.setStroke", json!({"ids": [red.0], "color": "#0000ff", "width": 2}));
select_all(&mut s);
let map = json!([
{"from": ["rgb 255 0 0", "rgb 255 128 128"], "to": "rgb 255 0 0"},
{"from": ["cmyk 100 0 0 0"], "to": "cmyk 100 0 0 0"},
{"from": ["rgb 0 0 255"], "to": "rgb 0 0 255"},
{"from": ["rgb 0 255 0"], "to": "rgb 0 255 0", "exclude": true},
]);
let undo = s.doc().unwrap().history.undo.len();
let sat = |c: Color| c.to_hsb()[1];
let saturations = [fill(&s, red), fill(&s, pink), fill(&s, cmyk), stroke(&s, red)].map(sat);
let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "globalAdjust": {"saturation": -50}}));
assert_eq!(r["changed"], 4, "three fills and a stroke: {r}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let after = [fill(&s, red), fill(&s, pink), fill(&s, cmyk), stroke(&s, red)].map(sat);
for (n, what) in ["red", "pink", "cyan", "the stroke"].iter().enumerate() {
// CMYK reads back through the profile-free conversion, so its saturation is near half, not exact.
assert!((after[n] - saturations[n] * 0.5).abs() < 0.05, "{what} has about half its saturation: {} → {}", saturations[n], after[n]);
}
assert!(close(fill(&s, red), Color::rgb(1.0, 0.5, 0.5)), "{:?}", fill(&s, red));
assert!(close(fill(&s, pink), Color::rgb(1.0, 0.75, 0.75)), "each colour of a row changes on its own: {:?}", fill(&s, pink));
assert!(matches!(fill(&s, cmyk), Color::Cmyk { .. }), "a CMYK colour stays CMYK: {:?}", fill(&s, cmyk));
assert_eq!(fill(&s, kept).to_hex(), "#00ff00", "an excluded row keeps its colours");
run(&mut s, "edit.undo", json!({}));
assert_eq!(fill(&s, red).to_hex(), "#ff0000");
assert_eq!(fill(&s, pink).to_hex(), "#ff8080");
assert_eq!(stroke(&s, red).to_hex(), "#0000ff");
// Brightness -100 makes every recolored color black; the rows' new colors are taken first.
run(&mut s, "recolor.apply", json!({"map": [{"from": ["#ff0000"], "to": "#00ff00"}], "globalAdjust": {"brightness": -100}}));
assert_eq!(fill(&s, red).to_hex(), "#000000");
assert_eq!(fill(&s, pink).to_hex(), "#ff8080", "a colour no row holds stays");
}
/// Global Adjust at 0 changes nothing, a global swatch's link included, and a non-zero adjustment
/// gives the art the adjusted colour without the link.
#[test]
fn global_adjust_at_zero_keeps_colors_and_links() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2878f0", "global": true}));
let art = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [art.0], "swatch": "Brand"}));
select_all(&mut s);
let link = |s: &Session| match s.doc().unwrap().doc.node(art).unwrap().appearance.fill_paint() {
vectorcraft_color::Paint::Solid { swatch, .. } => swatch,
p => panic!("a solid fill: {p:?}"),
};
let before = fill(&s, art);
let zero = json!({"saturation": 0, "brightness": 0, "temperature": 0, "luminosity": 0});
run(&mut s, "recolor.apply", json!({"map": {"rgb 40 120 240": "rgb 40 120 240"}, "globalAdjust": zero}));
assert_eq!(fill(&s, art), before);
assert_eq!(link(&s).as_deref(), Some("Brand"), "the link stays");
run(&mut s, "recolor.apply", json!({"map": {"rgb 40 120 240": "rgb 40 120 240"}, "globalAdjust": {"temperature": 50}}));
let [r, _, b] = fill(&s, art).to_rgb();
let [r0, _, b0] = before.to_rgb();
assert!(r > r0 && b < b0, "warmer: {:?}", fill(&s, art));
assert_eq!(link(&s), None, "the adjusted colour is no longer the swatch's");
}
/// With Limit to Library each row's new colour takes the Global Adjust and then snaps to the
/// library; a colour group rewritten with the rows' new colours takes the adjustment too.
#[test]
fn global_adjust_with_a_library_and_a_group() {
let mut s = session();
let art = rect(&mut s, json!("#f01020"));
select_all(&mut s);
run(&mut s, "recolor.apply", json!({"map": {"#f01020": "#f01020"}, "limitTo": "Web Safe 216", "globalAdjust": {"brightness": -100}}));
assert_eq!(fill(&s, art).to_hex(), "#000000", "black, a colour of the library");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#f01020"]}));
run(&mut s, "recolor.apply", json!({"map": [{"from": ["#f01020"], "to": "#f01020"}], "group": "Theme", "globalAdjust": {"saturation": -100}}));
let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().clone();
let c = g.swatches[0].paint.color().unwrap();
assert!(c.to_hsb()[1] < 1e-3, "the group's colour is adjusted: {c:?}");
assert_eq!(c, fill(&s, art), "as the art is");
}
/// Art linked to a global swatch of the rewritten group takes the same Global Adjust as unlinked
/// art of its color: at 100% it keeps the link, and a tint whose adjusted color is not the
/// swatch's new color at that tint takes the adjusted color and loses the link.
#[test]
fn global_adjust_gives_linked_tints_of_a_group_the_adjusted_color() {
let mut s = session();
run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["#ff0000"]}));
let name = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap().swatches[0].name.clone();
run(&mut s, "swatch.edit", json!({"name": name, "global": true}));
let full = rect(&mut s, json!("#000000"));
let tint = rect(&mut s, json!("#000000"));
run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": name}));
run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": name, "tint": 50}));
let link = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
vectorcraft_color::Paint::Solid { color, swatch, tint } => (color, swatch, tint),
p => panic!("{p:?}"),
};
select_all(&mut s);
let tint_key = vectorcraft_color::recolor::ColorKey::of(&fill(&s, tint)).to_string();
let rows = json!([{"from": ["rgb 255 0 0", tint_key], "to": "#ff0000"}]);
run(&mut s, "recolor.apply", json!({"map": rows, "method": "scaleTints", "group": "Theme", "globalAdjust": {"brightness": -50}}));
let (c, w, t) = link(&s, full);
assert!(close(c, Color::rgb(0.5, 0.0, 0.0)) && w.as_deref() == Some(name.as_str()) && t == 1.0, "{c:?} {w:?} {t}");
let (c, w, _) = link(&s, tint);
assert!(close(c, Color::rgb(0.5, 0.25, 0.25)), "the tint's own color, adjusted: {c:?}");
assert_eq!(w, None, "the adjusted tint is not a tint of the swatch's new color");
}
/// `globalAdjust` must be an object of numbers from -100 to 100; a refusal changes nothing and
/// records no undo step. A null counts as left out.
#[test]
fn global_adjust_refuses_params_of_the_wrong_form() {
let mut s = session();
let art = rect(&mut s, json!("#ff0000"));
select_all(&mut s);
let undo = s.doc().unwrap().history.undo.len();
let map = json!({"#ff0000": "#ff0000"});
for (adjust, msg) in [
(json!(5), "`globalAdjust` must be an object"),
(json!({"saturation": "50"}), "`saturation` must be a number"),
(json!({"brightness": 101}), "`brightness` must be between -100 and 100 (got 101)"),
(json!({"luminosity": -100.5}), "`luminosity` must be between -100 and 100 (got -100.5)"),
] {
let e = s.execute("recolor.apply", &json!({"map": map, "globalAdjust": adjust})).unwrap_err().to_string();
assert!(e.contains(msg), "{adjust}: {e}");
}
assert_eq!(fill(&s, art).to_hex(), "#ff0000", "nothing changed");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
run(&mut s, "recolor.apply", json!({"map": map, "globalAdjust": null}));
run(&mut s, "recolor.apply", json!({"map": map, "globalAdjust": {"saturation": null, "temperature": -100}}));
assert_ne!(fill(&s, art).to_hex(), "#ff0000", "an amount at the end of the range applies");
}