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:02:21 +07:00
parent 1de1c6446f
commit 2edac7e317
12 changed files with 533 additions and 144 deletions

1
Cargo.lock generated
View File

@ -4103,6 +4103,7 @@ dependencies = [
name = "vectorcraft-doc"
version = "0.1.1"
dependencies = [
"image",
"kurbo 0.13.1",
"proptest",
"serde",

View File

@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@ -12,6 +12,7 @@ pub mod live;
pub mod node;
pub mod overprint;
pub mod pattern;
mod pixels;
pub mod selection;
pub mod swatches;
pub mod text;

77
crates/doc/src/pixels.rs Normal file
View File

@ -0,0 +1,77 @@
//! Pixel access for embedded images: a stable content key and recolouring the pixels.
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::Arc;
use crate::ImageBlob;
impl ImageBlob {
/// A PNG blob of `png` bytes.
pub fn png(png: Vec<u8>) -> Self {
Self { mime: "image/png".into(), bytes: Arc::new(png) }
}
/// A key derived from the bytes (FNV-1a), the same for identical images.
pub fn content_key(&self) -> String {
let h = self.bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
format!("img{h:016x}")
}
/// The image with `f` applied to each pixel's (straight) RGB, alpha kept and fully transparent
/// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the
/// image can't be decoded or no pixel changed.
pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option<ImageBlob> {
let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8();
let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
let mut changed = false;
for px in img.pixels_mut().filter(|p| p.0[3] > 0) {
let rgb = [px.0[0], px.0[1], px.0[2]];
let out = *memo.entry(rgb).or_insert_with(|| f(rgb));
if out != rgb {
px.0[..3].copy_from_slice(&out);
changed = true;
}
}
if !changed {
return None;
}
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(Self::png(png))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn blob(px: &[[u8; 4]], w: u32, h: u32) -> ImageBlob {
let img = image::RgbaImage::from_raw(w, h, px.concat()).unwrap();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
ImageBlob::png(png)
}
fn pixels(b: &ImageBlob) -> Vec<[u8; 4]> {
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
#[test]
fn maps_each_colour_once_and_keeps_alpha() {
let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2);
let mut calls = 0;
let out = b
.map_rgb(|[r, g, bl]| {
calls += 1;
[255 - r, 255 - g, 255 - bl]
})
.unwrap();
assert_eq!(calls, 2, "red and blue, once each; the transparent pixel is skipped");
assert_eq!(pixels(&out), [[0, 255, 255, 255], [0, 255, 255, 128], [255, 255, 0, 255], [10, 20, 30, 0]]);
assert_ne!(out.content_key(), b.content_key());
assert_eq!(b.content_key(), blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2).content_key());
assert!(b.map_rgb(|c| c).is_none(), "nothing changed");
assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable");
}
}

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)
})?;

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);

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))
}

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 {

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 }))

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 }))
}

View File

@ -2,6 +2,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::Node;
use super::*;
@ -81,3 +82,149 @@ fn balance_previews_and_commits_one_step() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(fill_of(&s, a).to_hex(), "#808080");
}
// ---------- meshes, images and patterns ----------
fn mesh_colors(s: &Session, id: NodeId) -> Vec<Color> {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Mesh(m) => m.points.iter().map(|p| p.color).collect(),
_ => panic!("not a mesh"),
}
}
/// A selected 1×1 gradient mesh: red, with one green corner.
fn mesh(s: &mut Session) -> NodeId {
let a = rect(s, json!("#ff0000"));
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 0, "color": "#00ff00"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
a
}
#[test]
fn edit_colors_reach_mesh_points() {
let mut s = session();
let m = mesh(&mut s);
let undo = s.doc().unwrap().history.undo.len();
s.execute("edit.colors.toGrayscale", &json!({})).unwrap();
let pts = mesh_colors(&s, m);
assert!(pts.iter().all(|c| matches!(c, Color::Gray { .. })), "a grey mesh: {pts:?}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
s.execute("edit.colors.invert", &json!({"fill": false})).unwrap();
assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours");
}
#[test]
fn blend_moves_a_mesh_to_the_blended_colour() {
let mut s = session();
let left = rect(&mut s, json!("#000000"));
let m = mesh(&mut s);
s.execute("object.transform", &json!({"ids": [m.0], "matrix": [1, 0, 0, 1, 100, 0]})).unwrap();
let right = rect(&mut s, json!("#ffffff"));
s.execute("object.transform", &json!({"ids": [right.0], "matrix": [1, 0, 0, 1, 200, 0]})).unwrap();
s.execute("select.set", &json!({"ids": [left.0, m.0, right.0]})).unwrap();
let before = mesh_colors(&s, m);
s.execute("edit.colors.blendHorizontally", &json!({})).unwrap();
let after = mesh_colors(&s, m);
let mean = |cs: &[Color]| cs.iter().map(|c| c.to_rgb().iter().sum::<f32>() / 3.0).sum::<f32>() / cs.len() as f32;
assert!((mean(&after) - 0.5).abs() < 0.03, "{}", mean(&after));
assert_ne!(after[0], after[1], "the shading is kept");
assert_ne!(before, after);
}
/// A 2×2 RGBA image as a PNG.
fn png(px: [[u8; 4]; 4]) -> Vec<u8> {
let img = image::RgbaImage::from_raw(2, 2, px.concat()).unwrap();
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn pixels(s: &Session, id: NodeId) -> Vec<[u8; 4]> {
let d = &s.doc().unwrap().doc;
let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!("not an image") };
image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
/// A selected embedded 2×2 image.
fn image_node(s: &mut Session) -> NodeId {
let bytes = png([[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255, 255, 255, 128]]);
s.edit("Place", |d, sel| {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(bytes));
let id = d.alloc_id();
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 2, height: 2, xf: Default::default(), link: None };
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
#[test]
fn invert_recolours_an_embedded_image() {
let mut s = session();
let id = image_node(&mut s);
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("edit.colors.invert", &json!({})).unwrap();
assert_eq!(r["changed"], 1);
assert_eq!(pixels(&s, id), [[0, 255, 255, 255], [255, 0, 255, 255], [255, 255, 0, 255], [0, 0, 0, 128]], "inverted, alpha kept");
let d = &s.doc().unwrap().doc;
assert!(d.images.contains_key("pic"), "the original image stays for undo and other objects");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pixels(&s, id)[0], [255, 0, 0, 255]);
// Without images, nothing changes.
let r = s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap();
assert_eq!(r["changed"], 0);
// Recolor maps exact pixel colours.
s.execute("recolor.apply", &json!({"map": {"#ff0000": "#000000"}})).unwrap();
assert_eq!(pixels(&s, id)[..2], [[0, 0, 0, 255], [0, 255, 0, 255]]);
}
/// "Dots": a red square tile; a selected rectangle filled with it.
fn pattern_fill(s: &mut Session) -> NodeId {
rect(s, json!("#ff0000"));
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
r
}
fn tile_fill(s: &Session, pattern: &str) -> String {
let d = &s.doc().unwrap().doc;
d.pattern(pattern).unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex()
}
fn fill_pattern(s: &Session, id: NodeId) -> String {
match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
Paint::Pattern { pattern, .. } => pattern,
p => panic!("not a pattern fill: {p:?}"),
}
}
#[test]
fn recolor_writes_a_new_pattern_swatch() {
let mut s = session();
let r = pattern_fill(&mut s);
let colors = s.execute("recolor.colors", &json!({})).unwrap();
assert!(colors["colors"].as_array().unwrap().iter().any(|c| c["hex"] == "#ff0000"), "the tile's colours are listed: {colors}");
let undo = s.doc().unwrap().history.undo.len();
s.execute("recolor.apply", &json!({"map": {"#ff0000": "#0000ff"}})).unwrap();
assert_eq!(fill_pattern(&s, r), "Dots 2");
assert_eq!(tile_fill(&s, "Dots 2"), "#0000ff");
assert_eq!(tile_fill(&s, "Dots"), "#ff0000", "the original pattern is untouched");
let d = &s.doc().unwrap().doc;
assert!(d.swatch("Dots 2").is_some_and(|w| matches!(w.paint, Paint::Pattern { .. })), "a new pattern swatch");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(fill_pattern(&s, r), "Dots");
assert!(s.doc().unwrap().doc.pattern("Dots 2").is_none());
// Edit Colors recolour pattern fills the same way; patterns can be left out.
s.execute("edit.colors.invert", &json!({"includePatterns": false})).unwrap();
assert_eq!(fill_pattern(&s, r), "Dots");
s.execute("edit.colors.invert", &json!({})).unwrap();
assert_eq!((fill_pattern(&s, r), tile_fill(&s, "Dots 2")), ("Dots 2".to_string(), "#00ffff".to_string()));
}

View File

@ -259,3 +259,18 @@ it by multiplying. Older files that listed Overprint Black objects in the docume
{"name":"run_command","arguments":{"command":"attributes.info","params":{"ids":[12]}}}
{"name":"run_command","arguments":{"command":"view.overprintPreview","params":{"on":true}}}
```
## Edit Colors and Recolor Artwork
`edit.colors.invert`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, `edit.colors.saturate`,
`edit.colors.adjustBalance` and `recolor.apply` recolour everything inside the selection (or `ids`) in one undo step:
fills, strokes, text, gradient stops, gradient-mesh points, embedded images (a recoloured copy of the image; linked
images are left alone) and the tiles of pattern fills and strokes (a recoloured copy saved as a new pattern swatch,
e.g. "Dots 2"; the original pattern is untouched). `includeImages: false` / `includePatterns: false` leave images or
patterns out. The Blend commands also grade gradient meshes, which keep their shading.
```json
{"name":"run_command","arguments":{"command":"recolor.colors","params":{}}}
{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#0055ff"},"includeImages":false}}}
{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"cmyk","m":-20,"k":10}}}
```