From 29fb6bcd896674f38b398d515371f9ccf870d345 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 08:35:34 -0700 Subject: [PATCH] Images: color edits recolor a colorized image's fill paint.invert and paint.complement on the Fill proxy, the Edit Colors commands, the Blend commands and recolor.apply now recolor the color that colorizes a grayscale or 1-bit image, as one undo step with the other targets. Before, they left it unchanged. Expand Appearance and Object > Expand bake color adjustment effects into that color too. - A colorized image's pixels are not recolored; they stay the gray coverage of its color. A stroke-only pass reaches the image's stroke items and leaves its colorize color unchanged. - A command that would leave a process color on a grayscale image with an alpha channel fails with the existing message and changes nothing. Object > Expand undoes its earlier steps when that happens. - A colorized image counts as a filled object in a blend. --- crates/doc/src/recolor.rs | 19 +- crates/engine/src/cmd/colorcmds.rs | 20 +- crates/engine/src/cmd/effectcmd.rs | 2 + crates/engine/src/cmd/expand.rs | 17 +- crates/engine/src/cmd/paint.rs | 7 +- crates/engine/src/cmd/recolor.rs | 2 + crates/engine/src/tests/colorize.rs | 306 +++++++++++++++++++++++++++- docs/mcp.md | 23 ++- 8 files changed, 370 insertions(+), 26 deletions(-) diff --git a/crates/doc/src/recolor.rs b/crates/doc/src/recolor.rs index a5a878710..0efa82296 100644 --- a/crates/doc/src/recolor.rs +++ b/crates/doc/src/recolor.rs @@ -18,8 +18,8 @@ pub trait ColorVisitor { fn image(&mut self, key: &str) -> Option; } -/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of -/// embedded images. +/// What a recolouring pass reaches: fills (including colorized image fills and gradient-mesh +/// points), strokes and the pixels of embedded images without a colorize paint. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Reach { pub fill: bool, @@ -33,11 +33,18 @@ impl Reach { } /// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of -/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay -/// as they are). Members that change are replaced by copies; the others stay shared. Returns the -/// number of paints, meshes and images that changed. +/// its text runs, its gradient-mesh points and an embedded image's pixels (linked and colorized +/// images keep their pixels). Members that change are replaced by copies; the others stay shared. +/// Returns the number of paints, meshes and images that changed. pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize { let mut changed = 0; + let colorized = matches!(&n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some(); + if colorized + && reach.fill + && let Some(paint) = n.extras_mut().colorize.as_deref_mut() + { + changed += v.paint(paint) as usize; + } for it in &mut n.appearance.items { match it { AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize, @@ -67,7 +74,7 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi } // An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its // colours, as the glyph does in type. - NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => { + NodeKind::Image(im) if reach.images && !colorized && im.link.is_none() && im.pixels.is_none() => { if let Some(k) = v.image(&im.key) { im.key = k; changed += 1; diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 49b729c8a..2fadc11fd 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{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}", + "{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), colorized image fills (their pixels stay unchanged), other 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) ), @@ -178,10 +178,10 @@ impl Scope { /// 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. +/// pixels of embedded images without a colorize paint 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, /// Maps solid colours and gradient stops with their link and tint in place of `f`. @@ -341,7 +341,12 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result v, None => selected_roots(s)?, }; - let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); + let n = s.edit(label, |d, _| { + let n = pass.run(d, &ids); + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; + Ok(n) + })?; Ok(json!({ "changed": n })) } @@ -451,6 +456,7 @@ fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec { fn fill_color(n: &Node) -> Option { match &n.kind { NodeKind::Text(t) => t.first_style().fill.color(), + NodeKind::Image(_) => n.extras().colorize.as_deref().and_then(Paint::color).or_else(|| n.appearance.fill_paint().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]])?; @@ -487,6 +493,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { BlendOrder::Vertical => "Blend Vertically", }; let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let changed = s.edit(label, |d, _| { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { @@ -504,6 +511,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { }; changed += Recolor::new(fills, &*f).run(d, &[*id]); } + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) })?; Ok(json!({ "changed": changed })) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index bdd252da4..ec659abe8 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -732,6 +732,7 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br fn expand_appearance(s: &mut Session, p: &Value) -> Result { let roots = appearance_targets(s, p)?; let failed = vectorcraft_render::decode_failures(); + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let brushes = vectorcraft_brush::library(d); let mut out = vec![]; @@ -745,6 +746,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { if out.is_empty() { return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into())); } + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?; Ok(out) })?; let mut out = json!({ "ids": ids_json(&ids) }); diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 8dc49b089..b117b77a9 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -189,6 +189,15 @@ fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a F } fn expand(s: &mut Session, p: &Value) -> Result { + let scope = s.begin_undo_scope(); + let result = expand_steps(s, p); + if let Some(scope) = scope { + s.end_undo_scope(scope, result.is_err()); + } + result +} + +fn expand_steps(s: &mut Session, p: &Value) -> Result { let object = bool_or(p, "object", true); let fill = bool_or(p, "fill", true); let stroke = bool_or(p, "stroke", true); @@ -218,7 +227,13 @@ fn expand(s: &mut Session, p: &Value) -> Result { s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?; } if object && e.effects { - let _ = run_raw(s, "effect.expandAppearance", &json!({})); + // Effects that Expand Appearance can't expand are left as they are. When it refuses to + // leave a process color on a grayscale image with transparency, Expand fails as a whole. + if let Err(e) = run_raw(s, "effect.expandAppearance", &json!({})) + && super::paint::is_colorize_refusal(&e) + { + return Err(e); + } // What is left to expand is in the baked art: Expand Appearance leaves strokes strokes // (#3089), a group's own as stroked paths among its members, which Stroke outlines below. e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c0ca01b67..33c69589b 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -681,6 +681,11 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// Is `e` the refusal of [`check_colorized`]? +pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { + e.to_string().contains(PROCESS_ON_TRANSPARENCY) +} + /// Is `c` black or white (white, or black alone at 100%)? fn black_or_white(c: &Color) -> bool { let (none, full) = (|v: f32| v <= 0.0, |v: f32| v >= 1.0); @@ -835,7 +840,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( })?; (json!({ "changed": changed }), Some(index)) } - // The proxy's colours only: images and pattern tiles stay as they are. + // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); (super::colorcmds::recolor(s, &q, label, f)?, None) diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index d65ecfbd9..82d1f1cfd 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -480,6 +480,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { None => rows.iter().filter_map(Row::new_color).collect(), }; let rename = text_param(p, "rename", C)?; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let (changed, edit, added, converted) = s.edit(label, |d, _| { // The rows' library swatches join the document (one it has with that name and colour is // used as it is). @@ -519,6 +520,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; Ok((changed, edit, added, map.converted.get())) })?; let mut out = json!({ "changed": changed, "swatches": added }); diff --git a/crates/engine/src/tests/colorize.rs b/crates/engine/src/tests/colorize.rs index 444d365fd..bb9a9ac0f 100644 --- a/crates/engine/src/tests/colorize.rs +++ b/crates/engine/src/tests/colorize.rs @@ -1,7 +1,7 @@ //! A fill color colorizes grayscale and 1-bit images (`paint.setFill` on the image). use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, Node, NodeKind}; use crate::*; @@ -54,6 +54,287 @@ fn colorize(s: &Session, id: NodeId) -> Option { s.doc().unwrap().doc.node(id).unwrap().extras().colorize.as_deref().cloned() } +fn image_key(s: &Session, id: NodeId) -> String { + let d = &s.doc().unwrap().doc; + let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!("not an image") }; + im.key.clone() +} + +fn colorized_image_with_adjustment(alpha: bool) -> (Session, NodeId, Paint) { + let mut s = session(); + let id = image(&mut s, "gray", gray(alpha)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let before = colorize(&s, id).unwrap(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + (s, id, before) +} + +fn assert_adjustment_expand_rejected(command: &str) { + let (mut s, id, before) = colorized_image_with_adjustment(true); + let doc_before = s.doc().unwrap().doc.clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let e = s.execute(command, &json!({})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{command}: {e}"); + assert_eq!(colorize(&s, id), Some(before)); + assert_eq!(s.doc().unwrap().doc, doc_before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +fn assert_adjustment_expand_succeeds(command: &str) { + let (mut s, id, before) = colorized_image_with_adjustment(false); + s.execute(command, &json!({})).unwrap(); + assert_ne!(colorize(&s, id), Some(before)); + assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects.is_empty()); +} + +#[test] +fn expand_appearance_rejects_adjusted_colorized_gray_image_with_alpha() { + assert_adjustment_expand_rejected("effect.expandAppearance"); +} + +#[test] +fn expand_appearance_adjusts_colorized_gray_image_without_alpha() { + assert_adjustment_expand_succeeds("effect.expandAppearance"); +} + +#[test] +fn object_expand_rejects_adjusted_colorized_gray_image_with_alpha() { + assert_adjustment_expand_rejected("object.expand"); +} + +#[test] +fn object_expand_adjusts_colorized_gray_image_without_alpha() { + assert_adjustment_expand_succeeds("object.expand"); +} + +#[test] +fn object_expand_still_expands_a_gradient_beside_an_effect_that_cannot_expand() { + let mut s = session(); + let guide = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("select.set", &json!({"ids": [guide, rect]})).unwrap(); + s.execute("object.expand", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(NodeId(rect)).is_none_or(|n| !matches!(n.appearance.fill_paint(), Paint::Gradient(_))), "the gradient expanded"); + assert_eq!(doc.node(NodeId(guide)).unwrap().appearance.effects.len(), 1, "the guide keeps its effect"); +} + +#[test] +fn object_expand_restores_earlier_expansions_when_adjusted_colorize_is_rejected() { + let (mut s, id, before) = colorized_image_with_adjustment(true); + let a = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let blend = s.execute("object.blend.make", &json!({"ids": [a, b], "steps": 1})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id.0, blend]})).unwrap(); + let doc_before = s.doc().unwrap().doc.clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let e = s.execute("object.expand", &json!({})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, id), Some(before)); + assert_eq!(s.doc().unwrap().doc, doc_before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +fn blend_with_colorized_middle(alpha: bool) -> (Session, NodeId, Paint) { + let mut s = session(); + let back = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + let back = NodeId(back["id"].as_u64().unwrap()); + let middle = image(&mut s, "gray", gray(alpha)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + s.execute("object.transform", &json!({"ids": [middle.0], "matrix": [1, 0, 0, 1, 30, 0]})).unwrap(); + let before = colorize(&s, middle).unwrap(); + let front = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + let front = NodeId(front["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [back.0, middle.0, front.0]})).unwrap(); + (s, middle, before) +} + +#[test] +fn blend_rejects_a_process_color_on_a_colorized_gray_image_with_alpha() { + let (mut s, middle, before) = blend_with_colorized_middle(true); + let doc_before = s.doc().unwrap().doc.clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let e = s.execute("edit.colors.blendHorizontally", &json!({})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, middle), Some(before)); + assert_eq!(s.doc().unwrap().doc, doc_before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +#[test] +fn blend_recolors_a_colorized_gray_image_without_alpha() { + let (mut s, middle, _) = blend_with_colorized_middle(false); + assert_eq!(s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, middle), Some(Paint::solid(Color::rgb(0.5, 0.5, 0.5)))); +} + +#[test] +fn fill_proxy_invert_recolors_a_colorized_gray_image_and_undo_restores_it() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id).unwrap(); + assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into())); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#cc9966".into())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(before)); +} + +#[test] +fn fill_proxy_complement_recolors_a_colorized_gray_image_and_undo_restores_it() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let before = colorize(&s, id).unwrap(); + let expected = Paint::solid(Color::rgb8(0, 255, 255)); + assert_eq!(s.execute("paint.complement", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id), Some(expected.clone())); + assert_eq!(s.proxy_paints().0, expected); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(before)); +} + +#[test] +fn stroke_proxy_invert_leaves_a_colorized_image_fill_unchanged() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(255, 0, 0))); + assert_eq!(s.execute("paint.invert", &json!({"stroke": true})).unwrap(), json!({"changed": 1})); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255))); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn edit_colors_invert_without_fill_recolors_a_colorized_images_stroke() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false, "stroke": true})).unwrap(), json!({"changed": 1})); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255))); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn colorized_alpha_image_rejects_a_recolored_spot_but_accepts_inverted_black() { + let mut s = session(); + let id = image(&mut s, "alpha", gray(true)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let before = colorize(&s, id); + for cmd in ["paint.invert", "paint.complement", "edit.colors.invert"] { + let e = s.execute(cmd, &json!({"includeImages": false})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{cmd}: {e}"); + assert_eq!(colorize(&s, id), before, "{cmd}"); + } + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.invert", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 255, 255)))); +} + +#[test] +fn edit_colors_recolors_an_image_colorize_with_pixels_excluded() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let before = colorize(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255)))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), before); +} + +#[test] +fn edit_colors_invert_recolors_colorize_without_changing_pixels_and_undo_restores() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into())); + assert_eq!(image_key(&s, id), key); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn edit_colors_invert_recolors_colorized_image_inside_group() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let shape = NodeId(shape["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0, shape.0]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap(); + assert!(s.execute("edit.colors.invert", &json!({"ids": [group["id"]]})).unwrap()["changed"].as_u64().unwrap() >= 1); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into())); +} + +#[test] +fn edit_colors_invert_without_fill_leaves_colorized_image_unchanged() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false})).unwrap(), json!({"changed": 0})); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn recolor_apply_recolors_a_colorized_image_and_checks_alpha() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}, "includeImages": false})).unwrap()["changed"], 1); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 0)))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0)))); + + let mut s = session(); + let id = image(&mut s, "alpha", gray(true)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let before = colorize(&s, id); + let e = s.execute("recolor.apply", &json!({"map": {"cmyk 0 100 100 0": "#00ff00"}, "includeImages": false})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, id), before); +} + +#[test] +fn proxy_invert_recolors_a_colorized_image_and_another_fill_in_one_undo_step() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let shape = NodeId(shape["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0, shape.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 2})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255)))); + assert_eq!(s.doc().unwrap().doc.node(shape).unwrap().appearance.fill().unwrap().paint.color(), Some(Color::rgb8(255, 255, 0))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0)))); + assert_eq!(s.doc().unwrap().doc.node(shape).unwrap().appearance.fill().unwrap().paint.color(), Some(Color::rgb8(0, 0, 255))); +} + #[test] fn colorized_gray_image_is_flattened_for_pdf_13() { let mut s = session(); @@ -149,12 +430,33 @@ fn a_fill_color_colorizes_a_gray_image_and_none_takes_it_off() { assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(_), .. })), "undo restores the spot color"); s.execute("paint.setFill", &json!({"none": true})).unwrap(); assert_eq!(colorize(&s, id), None); - // `none` takes the color off after an edit of the pixels leaves the image in color. + // `none` takes the color off after an edit that leaves the image pixels gray. s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let key = image_key(&s, id); s.execute("edit.colors.invert", &json!({"includeImages": true})).unwrap(); let d = &s.doc().unwrap().doc; let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(im.key, key); + assert!(d.image_blob(im).and_then(|b| b.gray_source()).is_some(), "the pixels remain gray"); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + assert_eq!(colorize(&s, id), None); + + // `none` also takes the color off when a colorized image has color pixels. + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + s.edit("Replace Image", |d, _| { + d.images.insert("rgb".into(), png(image::DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 1, vec![255, 0, 0, 0, 0, 255]).unwrap()))); + if let Some(node) = d.node_mut(id) + && let NodeKind::Image(im) = &mut node.kind + { + im.key = "rgb".into(); + } + Ok(()) + }) + .unwrap(); + let d = &s.doc().unwrap().doc; + let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!() }; assert_eq!(d.image_blob(im).and_then(|b| b.gray_source()), None, "the pixels are no longer gray"); + assert!(colorize(&s, id).is_some()); s.execute("paint.setFill", &json!({"none": true})).unwrap(); assert_eq!(colorize(&s, id), None); } diff --git a/docs/mcp.md b/docs/mcp.md index 1f5bd6571..205079441 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -402,9 +402,10 @@ alpha channel takes only a spot color, black or white; a process color is refuse For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art). For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`. Lines start with no fill and a visible stroke. -The Fill/Stroke proxy commands run through `run_command`: `paint.invert` and `paint.complement` recolour the active -proxy keeping each colour's model, `paint.lastColor` / `paint.lastGradient` re-apply the last solid colour or gradient, -and `paint.recent` returns the recent colours that every paint command (and the eyedropper) feeds. +The Fill/Stroke proxy commands run through `run_command`: `paint.invert` and `paint.complement` recolor the active +proxy, including a grayscale image's colorized fill, while keeping each color's model. `paint.lastColor` / +`paint.lastGradient` re-apply the last solid color or gradient, and `paint.recent` returns the recent colors that every +paint command (and the eyedropper) feeds. `paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy). @@ -1809,13 +1810,15 @@ the scope and what differs from the public documentation. ## 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. `edit.colors.saturate` needs -`intensity`, a number from -100 to 100 (a number outside the range counts as its nearest end). Without one, or with a -value that is not a number, it answers `` missing number `intensity` `` and changes nothing. +`edit.colors.adjustBalance` and `recolor.apply` recolor everything inside the selection (or `ids`) in one undo step: +fills, strokes, text, gradient stops, gradient-mesh points, colorized image fills, embedded images (a recolored copy of +the image; linked images are left alone) and the tiles of pattern fills and strokes (a recolored copy saved as a new +pattern swatch, e.g. "Dots 2"; the original pattern is untouched). Colorized image fills are recolored when fills are in +scope, but their pixels are never recolored. `includeImages: false` leaves other images' pixels unchanged. +For `edit.colors.*`, `fill: false` leaves colorized image fills unchanged. `recolor.apply` always includes fills. +`includePatterns: false` leaves patterns out. The Blend commands also grade gradient meshes, which keep their shading. +`edit.colors.saturate` needs `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest +end). Without one, or with a value that is not a number, it answers `` missing number `intensity` `` and changes nothing. ```json {"name":"run_command","arguments":{"command":"recolor.colors","params":{}}}