M3.55: Edit Colors and Recolor Artwork also recolour gradient meshes, embedded images and pattern fills

One shared colour visitor (`colorcmds::Recolor`, used by Edit Colors, Recolor Artwork, the Blend
commands, the proxy Invert/Complement and Document Color Mode) maps solid colours, gradient stops
and text runs as before, and now also:
- gradient-mesh points (with fills); Blend grades meshes by their average and keeps their shading;
- embedded images' pixels (`includeImages?`, default on): the image becomes a recoloured copy under
  a content key, the original stays for undo and other objects; linked images are left alone;
- the tiles of pattern fills and strokes (`includePatterns?`, default on): one recoloured copy per
  pattern saved as a new pattern swatch ("Dots 2"), the original untouched.
Everything stays one undo step; only nodes that change are copied, in one walk of the tree.

- doc: `ImageBlob::map_rgb` (decode, map each distinct colour once, keep alpha, re-encode PNG),
  `ImageBlob::content_key` (now also used by image import) and `ImageBlob::png`.
- `recolor.colors` lists mesh points and pattern tile colours too.
- patterncmds: `add_pattern` adds a pattern and its swatch (shared by Make Pattern and Save a Copy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 00:16:08 +07:00
co-authored by Claude Opus 5.5
parent 1de1c6446f
commit 2edac7e317
12 changed files with 533 additions and 144 deletions
+234 -78
View File
@@ -1,15 +1,19 @@
//! Edit → Edit Colors: invert, convert to CMYK/Grayscale/RGB, saturate, adjust colour balance and the
//! three Blend commands. They recolour fills and strokes (solid colours, gradient stops and text
//! runs) of the selected objects and everything inside them. Colours linked to a global swatch are
//! three Blend commands. They recolour the selected objects and everything inside them through the
//! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text
//! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are
//! unlinked when they change.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::cms::Model;
use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation};
use vectorcraft_color::{Color, Paint, keep_model};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
use vectorcraft_doc::live::lerp_color;
use vectorcraft_doc::pattern::PatternDef;
use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind};
use super::edit::selected_roots;
use super::*;
@@ -21,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) → {changed}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}",
has_selection,
|s, p| recolor(s, p, "Invert Colors", &invert)
),
@@ -30,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to CMYK",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, ids?} → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
has_selection,
|s, p| recolor(s, p, "Convert to CMYK", &to_cmyk)
),
@@ -39,22 +43,30 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to Grayscale",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, ids?} → {changed}",
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
has_selection,
|s, p| recolor(s, p, "Convert to Grayscale", &to_gray)
),
cmd!("edit.colors.toRGB", "Convert to RGB", ["Edit", "Edit Colors"], None, "{fill?, stroke?, ids?} → {changed}", has_selection, |s, p| {
recolor(s, p, "Convert to RGB", &|c| {
let [r, g, b] = c.to_rgb();
Color::rgb(r, g, b)
})
}),
cmd!(
"edit.colors.toRGB",
"Convert to RGB",
["Edit", "Edit Colors"],
None,
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
has_selection,
|s, p| {
recolor(s, p, "Convert to RGB", &|c| {
let [r, g, b] = c.to_rgb();
Color::rgb(r, g, b)
})
}
),
cmd!(
"edit.colors.saturate",
"Saturate…",
["Edit", "Edit Colors"],
None,
"{intensity: -100..100 (%), fill?, stroke?, ids?} scale saturation by (1 + intensity/100) → {changed}",
"{intensity: -100..100 (%), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}",
has_selection,
saturate
),
@@ -63,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adjust Color Balance…",
["Edit", "Edit Colors"],
None,
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel, default 0), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, ids?} → {changed}. Global mode (tints of global and spot colours) isn't available yet",
"{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray?: -100..100 (% added per channel, default 0), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}. Global mode (tints of global and spot colours) isn't available yet",
has_selection,
adjust_balance
),
@@ -72,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Front to Back",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects: intermediate fills graded between the frontmost and backmost fill → {changed}",
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Stack)
),
@@ -81,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Horizontally",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects: fills graded between the leftmost and rightmost → {changed}",
"{} ≥3 filled objects or meshes: fills graded between the leftmost and rightmost → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Horizontal)
),
@@ -90,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Vertically",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects: fills graded between the topmost and bottommost → {changed}",
"{} ≥3 filled objects or meshes: fills graded between the topmost and bottommost → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Vertical)
),
@@ -149,49 +161,190 @@ pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
}
}
/// Recolour a node and its descendants. Returns the number of paints changed.
pub(crate) fn map_node_colors(n: &mut Node, f: &dyn Fn(Color) -> Color, fill: bool, stroke: bool) -> usize {
let mut count = 0;
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if fill => count += map_paint(&mut l.paint, f) as usize,
AppearanceItem::Stroke(l) if stroke => count += map_paint(&mut l.paint, f) as usize,
_ => {}
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
if fill {
count += map_paint(&mut r.style.fill, f) as usize;
}
if stroke {
count += map_paint(&mut r.style.stroke, f) as usize;
}
}
}
_ => {
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
count += map_node_colors(Arc::make_mut(c), f, fill, stroke);
}
}
}
}
count
/// What a recolouring pass covers: fills (with the points of gradient meshes), strokes, the pixels
/// of embedded images and the art of patterns (params `fill`, `stroke`, `includeImages`,
/// `includePatterns`; all on by default).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Scope {
pub fill: bool,
pub stroke: bool,
pub images: bool,
pub patterns: bool,
}
fn recolor_ids(s: &mut Session, label: &str, ids: Vec<NodeId>, fill: bool, stroke: bool, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let n = s.edit(label, |d, _| {
let mut n = 0;
for id in &ids {
if let Some(node) = d.node_mut(*id) {
n += map_node_colors(node, f, fill, stroke);
impl Scope {
pub(crate) fn of(p: &Value) -> Self {
Self {
fill: bool_or(p, "fill", true),
stroke: bool_or(p, "stroke", true),
images: bool_or(p, "includeImages", true),
patterns: bool_or(p, "includePatterns", true),
}
}
}
/// One recolouring pass, the colour visitor Edit Colors and Recolor Artwork share: `f` maps the
/// solid colours and gradient stops of fills, strokes and text runs, gradient-mesh points, the
/// pixels of embedded images and the art of patterns used as fills or strokes. A recoloured image
/// becomes a new image and a recoloured pattern a new pattern swatch ("Dots 2"); the originals stay
/// as they are for other objects. Colours that change lose their swatch link. Only nodes that
/// change are kept as copies.
pub(crate) struct Recolor<'a> {
f: &'a dyn Fn(Color) -> Color,
scope: Scope,
/// Image key → the key of its recoloured copy (`None`: unchanged).
images: HashMap<String, Option<String>>,
/// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured).
patterns: HashMap<String, Option<String>>,
new_images: Vec<(String, ImageBlob)>,
new_patterns: Vec<PatternDef>,
changed: usize,
}
impl<'a> Recolor<'a> {
pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self {
Self { f, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
}
/// Recolour the objects `ids` and everything inside them (each once), add the new images and
/// pattern swatches, and return the number of paints, meshes and images changed.
pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize {
let targets: HashSet<NodeId> = ids.iter().copied().collect();
let layers: Vec<(usize, Node)> = d.layers.iter().enumerate().filter_map(|(i, l)| Some((i, self.find(d, l, &targets)?))).collect();
for (i, l) in layers {
d.layers[i] = Arc::new(l);
}
for (k, b) in self.new_images {
d.images.entry(k).or_insert(b);
}
for mut def in self.new_patterns {
def.art = def.art.iter().map(|n| Arc::new(d.reid(n))).collect();
super::patterncmds::add_pattern(d, def);
}
self.changed
}
/// `n` with the targets inside it recoloured, or `None` when nothing changed.
fn find(&mut self, d: &Document, n: &Arc<Node>, targets: &HashSet<NodeId>) -> Option<Node> {
if targets.contains(&n.id) {
return self.subtree(d, n);
}
let mut out: Option<Node> = None;
for (i, c) in n.children()?.iter().enumerate() {
if let Some(new) = self.find(d, c, targets) {
out.get_or_insert_with(|| (**n).clone()).children_mut()?[i] = Arc::new(new);
}
}
Ok(n)
})?;
Ok(json!({ "changed": n }))
out
}
/// `n` recoloured, or `None` when nothing changed.
fn subtree(&mut self, d: &Document, n: &Node) -> Option<Node> {
let before = self.changed;
let mut m = n.clone();
self.node(d, &mut m);
(self.changed > before).then_some(m)
}
fn node(&mut self, d: &Document, n: &mut Node) {
let Scope { fill, stroke, images, .. } = self.scope;
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint),
AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint),
_ => {}
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
if fill {
self.paint(d, &mut r.style.fill);
}
if stroke {
self.paint(d, &mut r.style.stroke);
}
}
}
NodeKind::Mesh(m) if fill => {
let mut ch = false;
for p in &mut m.points {
let c = (self.f)(p.color);
ch |= c != p.color;
p.color = c;
}
self.changed += ch as usize;
}
NodeKind::Image(im) if images && im.link.is_none() => {
if let Some(k) = self.image(d, &im.key) {
im.key = k;
self.changed += 1;
}
}
_ => {
for c in n.children_mut().into_iter().flatten() {
if let Some(new) = self.subtree(d, c) {
*c = Arc::new(new);
}
}
}
}
}
fn paint(&mut self, d: &Document, p: &mut Paint) {
let changed = match p {
Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(),
_ => map_paint(p, self.f),
};
self.changed += changed as usize;
}
/// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes.
fn image(&mut self, d: &Document, key: &str) -> Option<String> {
if let Some(done) = self.images.get(key) {
return done.clone();
}
let f = self.f;
let new = d.images.get(key)?.map_rgb(|[r, g, b]| {
let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0);
[r, g, b]
});
let new = new.map(|blob| {
let k = blob.content_key();
self.new_images.push((k.clone(), blob));
k
});
self.images.insert(key.to_string(), new.clone());
new
}
/// The name of pattern `name` recoloured (one copy per pass), `None` when its colours stay.
fn pattern(&mut self, d: &Document, name: &str) -> Option<String> {
if let Some(done) = self.patterns.get(name) {
return done.clone();
}
// A pattern inside its own art is left as it is.
self.patterns.insert(name.to_string(), None);
let mut def = d.pattern(name)?.clone();
// The whole tile is recoloured, whichever of the object's paints uses the pattern.
let (scope, before) = (self.scope, self.changed);
self.scope = Scope { fill: true, stroke: true, ..scope };
for n in &mut def.art {
if let Some(new) = self.subtree(d, n) {
*n = Arc::new(new);
}
}
let changed = self.changed > before;
(self.scope, self.changed) = (scope, before);
if !changed {
return None;
}
def.name = unique_name(name, |n| d.swatch_name_taken(n) || d.pattern(n).is_some() || self.new_patterns.iter().any(|p| p.name == n));
let new = def.name.clone();
self.new_patterns.push(def);
self.patterns.insert(name.to_string(), Some(new.clone()));
Some(new)
}
}
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
@@ -199,7 +352,9 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color)
Some(v) => v,
None => selected_roots(s)?,
};
recolor_ids(s, label, ids, bool_or(p, "fill", true), bool_or(p, "stroke", true), f)
let scope = Scope::of(p);
let n = s.edit(label, |d, _| Ok(Recolor::new(scope, f).run(d, &ids)))?;
Ok(json!({ "changed": n }))
}
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
@@ -265,7 +420,7 @@ enum BlendOrder {
Vertical,
}
/// Leaf objects with a solid fill among the selection (paint order).
/// Leaf objects with a solid fill, and gradient meshes, among the selection (paint order).
fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
fn visit(n: &Node, out: &mut Vec<NodeId>) {
match &n.kind {
@@ -274,13 +429,8 @@ fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
visit(c, out);
}
}
NodeKind::Text(t) => {
if t.first_style().fill.color().is_some() {
out.push(n.id);
}
}
_ => {
if n.appearance.fill_paint().color().is_some() {
if fill_color(n).is_some() {
out.push(n.id);
}
}
@@ -295,23 +445,19 @@ fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
out
}
/// The colour an object blends by: its solid fill, or the average of a mesh's points.
fn fill_color(n: &Node) -> Option<Color> {
match &n.kind {
NodeKind::Text(t) => t.first_style().fill.color(),
NodeKind::Mesh(m) => {
let k = m.points.len() as f32;
let sum = m.points.iter().map(|p| p.color.to_rgb()).reduce(|a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]])?;
Some(Color::rgb(sum[0] / k, sum[1] / k, sum[2] / k))
}
_ => n.appearance.fill_paint().color(),
}
}
/// Interpolate within the endpoints' model when they share one.
pub(crate) fn lerp_model(a: Color, b: Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (a, b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
_ => a.lerp(&b, t),
}
}
fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
@@ -338,13 +484,23 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
BlendOrder::Horizontal => "Blend Horizontally",
BlendOrder::Vertical => "Blend Vertically",
};
let fills = Scope { fill: true, stroke: false, images: false, patterns: false };
let changed = s.edit(label, |d, _| {
let mut changed = 0;
for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) {
let c = lerp_model(first, last, i as f32 / (n - 1) as f32).in_model(model);
if let Some(node) = d.node_mut(*id) {
changed += map_node_colors(node, &|_| c, true, false);
}
let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model);
// A mesh moves its average to the blended colour and keeps its shading.
let f: Box<dyn Fn(Color) -> Color> = match d.node(*id).map(|n| (&n.kind, fill_color(n))) {
Some((NodeKind::Mesh(_), Some(mean))) => {
let ([cr, cg, cb], [mr, mg, mb]) = (c.to_rgb(), mean.to_rgb());
Box::new(move |p| {
let [r, g, b] = p.to_rgb();
keep_model(p, Color::rgb((r + cr - mr).clamp(0.0, 1.0), (g + cg - mg).clamp(0.0, 1.0), (b + cb - mb).clamp(0.0, 1.0)))
})
}
_ => Box::new(move |_| c),
};
changed += Recolor::new(fills, &*f).run(d, &[*id]);
}
Ok(changed)
})?;
+3 -4
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind};
use super::colorcmds::{map_node_colors, to_cmyk};
use super::colorcmds::{Recolor, Scope, to_cmyk};
use super::edit::selected_roots;
use super::*;
@@ -466,9 +466,8 @@ fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Document Color Mode", |d, _| {
d.color_mode = mode;
if convert {
for l in &mut d.layers {
map_node_colors(Arc::make_mut(l), &f, true, true);
}
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers);
for sw in d.swatches_iter_mut() {
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
*color = f(*color);
+2 -6
View File
@@ -143,7 +143,8 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
if width == 0 || height == 0 {
return Err(err("the image is empty"));
}
Ok(RasterImage { key: format!("img{:016x}", fnv1a(&bytes)), blob: ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) }, width, height })
let blob = ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) };
Ok(RasterImage { key: blob.content_key(), blob, width, height })
}
/// An image as a document of its pixel size (1 px = 1 pt), the image named after the file.
@@ -164,8 +165,3 @@ fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?;
Ok(out)
}
/// FNV-1a: a stable content key for embedded images.
fn fnv1a(b: &[u8]) -> u64 {
b.iter().fold(0xcbf29ce484222325, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3))
}
+2 -1
View File
@@ -435,7 +435,8 @@ fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> C
s.remember_paint(&shown);
return Ok(json!({ "changed": changed as usize }));
}
let q = json!({"fill": !stroke, "stroke": stroke, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
// The proxy's colours only: images and pattern tiles stay as they are.
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
let r = super::colorcmds::recolor(s, &q, label, f)?;
let first = leaf_targets(s, &ids)?.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, None)));
if let Some(shown) = first {
+8 -4
View File
@@ -219,6 +219,12 @@ fn next_pattern_name(d: &Document) -> String {
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap()
}
/// Add pattern `def` and its swatch.
pub(crate) fn add_pattern(d: &mut Document, def: PatternDef) {
d.swatches.push(vectorcraft_color::Swatch { name: def.name.clone(), paint: pattern_paint(&def.name), global: false, spot: false });
d.patterns.push(def);
}
/// Fresh-id deep copies of `nodes`.
fn copies(d: &mut Document, nodes: &[Arc<Node>]) -> Vec<Node> {
nodes.iter().map(|n| d.reid(n)).collect()
@@ -299,8 +305,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
def.tile_type = tt;
let c = def.tile.center();
def.tile = Rect::from_center_size(c, (w.unwrap_or(def.tile.width()).max(1e-3), h.unwrap_or(def.tile.height()).max(1e-3)));
d.patterns.push(def);
d.swatches.push(vectorcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false });
add_pattern(d, def);
if edit {
enter_edit(d, sel, &name, None)?;
}
@@ -393,8 +398,7 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
if def.size_tile_to_art {
def.fit_tile_to_art();
}
d.patterns.push(def);
d.swatches.push(vectorcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false });
add_pattern(d, def);
Ok(name)
})?;
Ok(json!({ "name": name }))
+42 -51
View File
@@ -4,62 +4,64 @@ use std::collections::BTreeMap;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, keep_model};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_doc::swatches::node_colors;
use vectorcraft_doc::{AppearanceItem, NodeKind};
use super::colorcmds::{Recolor, Scope};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "recolor.colors", "Artwork Colors", [], None, "{} → {colors: [{hex, count}]} unique colours used by the selection (fills, strokes, gradient stops, text)", has_selection, colors),
cmd!(
query "recolor.colors",
"Artwork Colors",
[],
None,
"{} → {colors: [{hex, count}]} 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,
colors
),
cmd!(
"recolor.apply",
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: {\"#rrggbb\": \"#rrggbb\", …}} replace colours across the selection (gradients and text included); each new colour keeps the model of the one it replaces",
"{map: {\"#rrggbb\": \"#rrggbb\", …}, includeImages?: true (pixels of those colours in embedded images too; the image becomes a recoloured copy), includePatterns?: true (a pattern fill or stroke gets a recoloured copy as a new pattern swatch; the original pattern stays)} replace colours across the selection (gradients, text and meshes included; each new colour keeps the model of the one it replaces) as one undo step → {changed}",
has_selection,
apply
),
]
}
fn visit_paints(n: &mut Node, f: &mut impl FnMut(&mut Color)) {
let mut paint = |p: &mut Paint| match p {
Paint::Solid { color, .. } => f(color),
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
f(&mut s.color);
}
}
_ => {}
};
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(fl) => paint(&mut fl.paint),
AppearanceItem::Stroke(st) => paint(&mut st.paint),
}
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
paint(&mut r.style.fill);
paint(&mut r.style.stroke);
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
visit_paints(std::sync::Arc::make_mut(c), f);
}
}
}
fn colors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let d = &st.doc;
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for id in &st.selection.objects {
if let Some(n) = st.doc.node(*id) {
let mut n = n.clone();
visit_paints(&mut n, &mut |c| *counts.entry(c.to_hex()).or_default() += 1);
}
let mut count = |c: &Color, _: Option<&str>| *counts.entry(c.to_hex()).or_default() += 1;
// The patterns used as fills or strokes: their tiles count once each.
let mut patterns: Vec<&str> = vec![];
for n in st.selection.objects.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);
}
}
});
}
for def in patterns.iter().filter_map(|p| d.pattern(p)) {
def.art.iter().for_each(|n| node_colors(n, &mut count));
}
let mut v: Vec<(String, usize)> = counts.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
@@ -75,19 +77,8 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(k, v)| Some((Color::from_hex(k)?.to_hex(), color_value(v)?)))
.collect();
let ids = s.doc()?.selection.objects.clone();
let mut changed = 0usize;
s.edit("Recolor Artwork", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
visit_paints(n, &mut |c| {
if let Some(to) = map.get(&c.to_hex()) {
*c = keep_model(*c, *to);
changed += 1;
}
});
}
}
Ok(())
})?;
let f = |c: Color| map.get(&c.to_hex()).map_or(c, |to| keep_model(c, *to));
let scope = Scope { fill: true, stroke: true, ..Scope::of(p) };
let changed = s.edit("Recolor Artwork", |d, _| Ok(Recolor::new(scope, &f).run(d, &ids)))?;
Ok(json!({ "changed": changed }))
}