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.
This commit is contained in:
dotCooCoo
2026-10-30 23:44:19 +07:00
committed by bflatastic
parent 6c9933666b
commit 29fb6bcd89
8 changed files with 370 additions and 26 deletions
+13 -6
View File
@@ -18,8 +18,8 @@ pub trait ColorVisitor {
fn image(&mut self, key: &str) -> Option<String>;
}
/// 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;
+14 -6
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Va
Some(v) => 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<NodeId> {
fn fill_color(n: &Node) -> Option<Color> {
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<Value> {
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<Value> {
};
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 }))
+2
View File
@@ -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<Value> {
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<Value> {
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) });
+16 -1
View File
@@ -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<Value> {
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<Value> {
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<Value> {
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)?);
+6 -1
View File
@@ -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::<Vec<_>>()});
(super::colorcmds::recolor(s, &q, label, f)?, None)
+2
View File
@@ -480,6 +480,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
let lf = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| map.map_link(c, link, tint);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
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 });
+304 -2
View File
@@ -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<Paint> {
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);
}
+13 -10
View File
@@ -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":{}}}