From dc79bbbe73791bd682329352e621bc072b47f315 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:07:59 -0800 Subject: [PATCH] Image Tools: Color Mask refuses a process color on each gray image it masks Color Mask checked the whole document for a grayscale image colorized with a process color only when the document passed that check before the run. With another such image already in the document, the check was skipped, and a process-colorized gray image could take masked pixels with transparency. Each image whose mask changes is now checked on the pixels the new mask shows, made from the image's own pixels, so an image elsewhere in the document no longer affects the result. A mask that hides nothing keeps the image's own pixels and is still set. When new pixels replace a masked gray image's own pixels under a process color, the mask made for them hides nothing, and Apply Color Mask refuses them. The check shares its colorize predicate with paint's check_colorized. --- crates/engine/src/cmd/addons/image/mask.rs | 49 ++++--- .../engine/src/cmd/addons/image/mask/tests.rs | 124 +++++++++++++++++- crates/engine/src/cmd/paint.rs | 24 +++- docs/mcp.md | 12 +- 4 files changed, 182 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/addons/image/mask.rs b/crates/engine/src/cmd/addons/image/mask.rs index d6dc451f4f..786effbd2e 100644 --- a/crates/engine/src/cmd/addons/image/mask.rs +++ b/crates/engine/src/cmd/addons/image/mask.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::color_mask::{ColorMask, MaskBackground, MaskMatch}; use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; -use super::super::super::paint::{check_colorized, check_colorized_kept}; +use super::super::super::paint::{PROCESS_ON_TRANSPARENCY, colorized_with_process}; use super::super::super::{CommandSpec, Result, Session, bad, cmd}; use super::{Background, DecodedImages, MAX_COLORS, Match, Settings, clear, images, targets}; @@ -38,7 +38,7 @@ pub fn specs() -> Vec { "Color Mask…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges) | \"corners\" (the average of the image's four corner pixels that are not clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 hex colors or black, white, red, green and blue, as addon.image.removeBackground takes them; needed with background: colors), contiguous?: bool (true: only matching pixels that reach in from an edge are hidden; false: wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a hidden color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (% as addon.image.removeBackground measures it; a pixel this near a hidden color is hidden; 10), softness?: 0..100 (% past the tolerance over which pixels fade from hidden to shown; 10), ids?: [id…] (default: the selected images)} hide colors of each image without changing its pixels, as one undo step: the image keeps its own pixels, and the mask (these settings, and the image's pixels with the hidden colors made clear, made as Remove Background makes them) is kept with the image and saved with the document; the canvas, printing and separations, PDF, SVG, EPS, raster formats, DXF, EMF, WMF, the legacy .ai and the .ai editing data, copy and paste, and placing a document show the masked pixels, and a click on any part of the image's frame selects it (the Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels); setting the mask again replaces it, and an image whose mask does not change is left as it is (no undo step when none changes); when the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, a linked file read again on open), the mask is made again for the new pixels; an object in `ids` that is not an image, a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a mask that would hide every pixel, and a grayscale image colorized with a process color (masked gray pixels have transparency, which only a spot color, black or white colorizes) are refused. With none of background, colors, contiguous, match, tolerance and softness given, nothing changes and the mask settings of the first image (or the defaults when it has no mask) are returned → {changed: the images changed, ids, hidden: the pixels made fully clear in them} or {background, colors, contiguous, match, tolerance, softness, masked}", + "{background?: \"edges\" (default: the most common color along the image's edges) | \"corners\" (the average of the image's four corner pixels that are not clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 hex colors or black, white, red, green and blue, as addon.image.removeBackground takes them; needed with background: colors), contiguous?: bool (true: only matching pixels that reach in from an edge are hidden; false: wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a hidden color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (% as addon.image.removeBackground measures it; a pixel this near a hidden color is hidden; 10), softness?: 0..100 (% past the tolerance over which pixels fade from hidden to shown; 10), ids?: [id…] (default: the selected images)} hide colors of each image without changing its pixels, as one undo step: the image keeps its own pixels, and the mask (these settings, and the image's pixels with the hidden colors made clear, made as Remove Background makes them) is kept with the image and saved with the document; the canvas, printing and separations, PDF, SVG, EPS, raster formats, DXF, EMF, WMF, the legacy .ai and the .ai editing data, copy and paste, and placing a document show the masked pixels, and a click on any part of the image's frame selects it (the Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels); setting the mask again replaces it, and an image whose mask does not change is left as it is (no undo step when none changes); when the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, a linked file read again on open), the mask is made again for the new pixels; an object in `ids` that is not an image, a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a mask that would hide every pixel, and a mask that would hide pixels of a grayscale image colorized with a process color (masked gray pixels have transparency, which only a spot color, black or white colorizes; each image is checked on the pixels its new mask shows) are refused. With none of background, colors, contiguous, match, tolerance and softness given, nothing changes and the mask settings of the first image (or the defaults when it has no mask) are returned → {changed: the images changed, ids, hidden: the pixels made fully clear in them} or {background, colors, contiguous, match, tolerance, softness, masked}", super::has_image, color_mask ), @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Apply Color Mask", ["Object", "Plug-ins", "Image Tools"], None, - "{ids?: [id…] (default: the selected images)} make each image's color mask part of its pixels, as one undo step: the masked pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is taken off; a mask that hides nothing is only taken off (the image keeps its pixels and its link); images without a mask are left as they are (no undo step when none has one), and an object in `ids` that is not an image is refused → {changed, ids}", + "{ids?: [id…] (default: the selected images)} make each image's color mask part of its pixels, as one undo step: the masked pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is taken off; a mask that hides nothing is only taken off (the image keeps its pixels and its link); images without a mask are left as they are (no undo step when none has one), and an object in `ids` that is not an image, and a mask made again for new pixels that would give a grayscale image colorized with a process color an alpha channel, are refused → {changed, ids}", has_color_mask, apply_color_mask ), @@ -128,7 +128,6 @@ fn color_mask(s: &mut Session, p: &Value) -> Result { let ids: Vec = changes.iter().map(|c| c.0.0).collect(); let hidden: u64 = changes.iter().map(|c| c.3).sum(); if !changes.is_empty() { - let colorized_ok = check_colorized(&s.doc()?.doc).is_ok(); s.edit("Color Mask", |d, _| { for (id, mask, blob, _) in changes { if let Some(blob) = blob { @@ -138,9 +137,7 @@ fn color_mask(s: &mut Session, p: &Value) -> Result { n.extras_mut().color_mask = Some(Box::new(mask)); } } - // Masked pixels have an alpha channel, which a grayscale image colorized with a process - // color can't take. - check_colorized_kept(colorized_ok, d).map_err(|e| bad(COLOR_MASK, e)) + Ok(()) })?; } Ok(json!({ "changed": ids.len(), "ids": ids, "hidden": hidden })) @@ -171,12 +168,26 @@ fn mask_changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&m if st.doc.node(id).and_then(Document::color_mask_of) == Some(&mask) { continue; } + // Masked pixels that hide something have an alpha channel, which a grayscale image + // colorized with a process color can't take. The pixels the new mask shows are checked, + // made from the image's own pixels, not the ones an earlier mask shows. + let shown = pixels.as_ref().or_else(|| st.doc.images.get(&mask.key)); + if st.doc.node(id).is_some_and(|n| refused_colorize(&st.doc, n, shown)) { + return Err(bad(COLOR_MASK, PROCESS_ON_TRANSPARENCY)); + } let pixels = pixels.filter(|_| !st.doc.images.contains_key(&mask.key)); out.push((id, mask, pixels, hidden)); } Ok(out) } +/// Whether masked pixels `shown`, made for image node `n`, have transparency that `n`'s colorize +/// color can't take: gray pixels with an alpha channel and a process color +/// ([`colorized_with_process`]). +fn refused_colorize(doc: &Document, n: &Node, shown: Option<&ImageBlob>) -> bool { + shown.and_then(ImageBlob::gray_source).is_some_and(|g| g.alpha) && colorized_with_process(doc, n) +} + /// Color Mask without settings: the first image's mask settings, or the defaults. It reads them /// for images a change would refuse too (CMYK, a linked image at its preview), so the dialog opens. fn report(s: &Session, p: &Value) -> Result { @@ -239,7 +250,11 @@ fn apply_color_mask(s: &mut Session, p: &Value) -> Result { made.push((*id, None)); continue; } - let size = mask_size(st.doc.images.get(&mask.0.key).or(mask.1.as_ref()), im.width, im.height); + let shown = st.doc.images.get(&mask.0.key).or(mask.1.as_ref()); + if refused_colorize(&st.doc, n, shown) { + return Err(bad(APPLY_COLOR_MASK, PROCESS_ON_TRANSPARENCY)); + } + let size = mask_size(shown, im.width, im.height); made.push((*id, Some((mask, size)))); } } @@ -371,11 +386,12 @@ fn stale_mask(doc: &Document, n: &Node) -> bool { if im.pixels.is_none() && !(m.made_for(im) && (m.key == im.key || doc.images.contains_key(&m.key)))) } -/// One [`refresh`] pass: the masks made so far, by the image's pixels and the mask's settings (an -/// image shown by many objects is decoded once), and the masked pixels to add to the document. +/// One [`refresh`] pass: the masks made so far, by the image's pixels, the mask's settings and +/// whether a process color colorizes the image (an image shown by many objects is decoded once), +/// and the masked pixels to add to the document. struct Refresh<'a> { doc: &'a Document, - made: HashMap<(String, String), ColorMask>, + made: HashMap<(String, String, bool), ColorMask>, blobs: Vec<(String, ImageBlob)>, } @@ -399,24 +415,27 @@ impl Refresh<'_> { /// The mask of image node `n` made again for the image's own pixels ([`fresh`]), `None` when /// it is up to date. A mask that can't be made for them (every pixel hidden, CMYK pixels, a - /// linked preview, too large) keeps its settings and hides nothing: the image shows its own - /// pixels, and setting the mask again reports why. + /// linked preview, too large, gray pixels with transparency under a process color) keeps its + /// settings and hides nothing: the image shows its own pixels, and setting the mask again + /// reports why. fn made(&mut self, n: &Node) -> Option { let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) else { return None }; if !stale_mask(self.doc, n) { return None; } let settings = ColorMask { key: String::new(), from: String::new(), ..m.clone() }; - let at = (im.key.clone(), format!("{settings:?}")); + let at = (im.key.clone(), format!("{settings:?}"), colorized_with_process(self.doc, n)); if let Some(made) = self.made.get(&at) { return Some(made.clone()); } + let hides_nothing = || ColorMask { key: im.key.clone(), from: im.key.clone(), ..m.clone() }; let mask = match fresh(self.doc, n, COLOR_MASK)? { + Ok((mask, blob)) if refused_colorize(self.doc, n, blob.as_ref().or_else(|| self.doc.images.get(&mask.key))) => hides_nothing(), Ok((mask, blob)) => { self.blobs.extend(blob.map(|b| (mask.key.clone(), b))); mask } - Err(_) => ColorMask { key: im.key.clone(), from: im.key.clone(), ..m.clone() }, + Err(_) => hides_nothing(), }; self.made.insert(at, mask.clone()); Some(mask) diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index 071c82bc7a..fb978235d9 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -2,8 +2,9 @@ use std::io::Cursor; use image::DynamicImage; use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::color_mask::{ColorMask, MaskBackground}; -use vectorcraft_doc::{Document, ImageBlob, ImageObject, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::{APPLY_COLOR_MASK, COLOR_MASK, REMOVE_COLOR_MASK}; @@ -406,6 +407,127 @@ fn a_process_color_does_not_colorize_masked_gray_pixels() { assert!(e.contains(REFUSED), "{e}"); } +/// Another image that already has an alpha channel and a process colorize color, as an opened file +/// can hold, neither lets a grayscale image colorized with a process color take a mask nor stops a +/// mask on an image that can take one. +#[test] +fn another_image_colorized_with_transparency_does_not_change_the_refusal() { + const REFUSED: &str = "a process color can't colorize a grayscale image with transparency"; + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_pixel(2, 2, image::LumaA([128, 100]))))); + s.edit("Place", |d, _| { + let key = held.content_key(); + d.images.insert(key.clone(), held.clone()); + let other = d.alloc_id(); + let im = ImageObject { key, width: 2, height: 2, xf: Default::default(), link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(other, NodeKind::Image(Box::new(im))); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + d.insert(d.default_layer(), 0, n)?; + Ok(()) + }) + .unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![id], "only the gray image is selected"); + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(COLOR_MASK) && e.contains(REFUSED), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps), "with another image refused already, nothing changes"); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + assert_eq!(s.execute(COLOR_MASK, &hide_white()).unwrap()["changed"], 1, "an image colorized with black takes the mask beside the other"); + let m = mask_of(doc(&s), id).unwrap(); + let masked = image::load_from_memory(&doc(&s).images[&m.key].bytes).unwrap().to_luma_alpha8(); + assert_eq!((masked.get_pixel(0, 0).0[1], masked.get_pixel(4, 4).0[1]), (0, 255), "white is hidden and black shows"); +} + +/// The mask is made from the image's own pixels: a grayscale image colorized with a process color +/// is refused even when an earlier mask, as an opened file can hold, shows color pixels. +#[test] +fn a_gray_image_whose_earlier_mask_shows_color_pixels_is_refused() { + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let rgb = ImageBlob::png(framed()); + s.edit("Mask", |d, _| { + let key = rgb.content_key(); + d.images.insert(key.clone(), rgb.clone()); + let from = image_of(d, id).key; + let mask = ColorMask { key, from, ..mask_of_settings() }; + d.node_mut(id).unwrap().extras_mut().color_mask = Some(Box::new(mask)); + Ok(()) + }) + .unwrap(); + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); +} + +/// A mask that hides nothing leaves the image's own pixels without transparency, so a grayscale +/// image colorized with a process color takes it. +#[test] +fn a_mask_that_hides_nothing_on_a_gray_image_colorized_with_a_process_color_is_set() { + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let none = json!({"background": "colors", "colors": "#808080", "contiguous": false, "tolerance": 0, "softness": 0}); + let r = s.execute(COLOR_MASK, &none).unwrap(); + assert_eq!((r["changed"].as_u64(), r["hidden"].as_u64()), (Some(1), Some(0))); + let (m, im) = (mask_of(doc(&s), id).unwrap(), image_of(doc(&s), id)); + assert_eq!(m.key, im.key, "the masked pixels are the image's own"); + assert!(doc(&s).node(id).unwrap().extras().colorize.is_some(), "the image stays colorized"); +} + +/// New gray pixels whose colors a mask hides would take transparency, which a process color can't +/// colorize: the mask made again for them hides nothing, and Apply Color Mask refuses them. +#[test] +fn new_gray_pixels_under_a_process_color_are_not_masked() { + const REFUSED: &str = "a process color can't colorize a grayscale image with transparency"; + let gray = json!({"background": "colors", "colors": "#808080", "contiguous": false, "tolerance": 0, "softness": 0}); + let swapped = + || ImageBlob::png(png(DynamicImage::ImageLuma8(image::GrayImage::from_fn(12, 10, |x, _| image::Luma([if x < 6 { 128 } else { 0 }]))))); + let setup = || { + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.execute(COLOR_MASK, &gray).unwrap()["hidden"], 0, "no pixel is gray yet"); + (s, id) + }; + // Pixels put in place by an edit: the mask is made again for them and hides nothing. + let (mut s, id) = setup(); + s.edit("Swap", |d, _| { + let blob = swapped(); + let key = blob.content_key(); + d.images.insert(key.clone(), blob); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key; + } + Ok(()) + }) + .unwrap(); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!((m.from.as_str(), m.key.as_str()), (im.key.as_str(), im.key.as_str()), "the mask hides nothing"); + assert_eq!(crate::cmd::paint::check_colorized(d), Ok(())); + // Pixels put in place without an edit: Apply Color Mask makes the mask again and refuses it. + let (mut s, id) = setup(); + let blob = swapped(); + let key = blob.content_key(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.images.insert(key.clone(), blob); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key; + } + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(APPLY_COLOR_MASK, &json!({})).unwrap_err().to_string(); + assert!(e.contains(APPLY_COLOR_MASK) && e.contains(REFUSED), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); +} + +/// A mask that hides white, made for another document's image (only its settings are of use). +fn mask_of_settings() -> ColorMask { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + mask_of(doc(&s), id).unwrap() +} + /// Images outside the layers (a symbol's art, opacity-mask art) take their masks again too when /// their own pixels are replaced, as Update Link replaces them there. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 48398e1fdd..df388b81ab 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; @@ -720,7 +720,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: } /// The message for a process color on a grayscale image with an alpha channel. -const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; +pub(super) const PROCESS_ON_TRANSPARENCY: &str = + "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; /// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch /// of `d`, black or white? @@ -739,11 +740,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { let mut refused = false; let mut visit = |n: &Node| { vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) - && !refused - && !colorizes_transparency(d, paint) - && d.image_blob(&d.shown_image(m, im)).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) - { + if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) { refused = true; } }) @@ -763,6 +760,19 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// Is `n` an image colorized with a color that [`colorizes_transparency`] refuses? When its pixels +/// are grayscale or 1-bit, they can't have an alpha channel. +pub(super) fn colorized_with_process(d: &Document, n: &Node) -> bool { + matches!(n.kind, NodeKind::Image(_)) && n.extras().colorize.as_deref().is_some_and(|paint| !colorizes_transparency(d, paint)) +} + +/// How the pixels `n` shows are gray, when `n` is a grayscale or 1-bit image colorized with a +/// color that [`colorizes_transparency`] refuses ([`colorized_with_process`]). +pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option { + let NodeKind::Image(im) = &n.kind else { return None }; + if colorized_with_process(d, n) { d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) } else { None } +} + /// Is `e` the refusal of [`check_colorized`]? pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { e.to_string().contains(PROCESS_ON_TRANSPARENCY) diff --git a/docs/mcp.md b/docs/mcp.md index 6d02cf14d1..b4964c10f8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10131,8 +10131,9 @@ profile and resolution). PDF's color key masking hides chosen colors of an image show the masked pixels. A linked image stays linked. An output that refers to linked files instead of embedding them (SVG with `images: "link"`) writes the masked pixels in place of the link. - A grayscale image's masked pixels have an alpha channel. A colorize color on a masked grayscale image follows the rule - for grayscale images with transparency (a spot color, black or white), and Color Mask refuses a grayscale image - colorized with a process color. + for grayscale images with transparency (a spot color, black or white), and Color Mask refuses a mask that hides + pixels of a grayscale image colorized with a process color. Each image is checked on the pixels its new mask shows, + whatever other images in the document hold. - A click anywhere in the image's frame selects it, hidden pixels included, as it does for an image with clear pixels. The Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels, hidden colors included. @@ -10141,7 +10142,8 @@ profile and resolution). PDF's color key masking hides chosen colors of an image `hidden` counts the pixels made fully clear in the images that changed. - When the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, or a linked file read again when the document opens), the mask is made again for the new pixels. When the new pixels can't be masked (CMYK - pixels, missing pixels, or every pixel would be hidden), the mask keeps its settings and hides nothing. + pixels, missing pixels, every pixel would be hidden, or the image is grayscale and colorized with a process color + and the mask would hide some of its pixels), the mask keeps its settings and hides nothing. - The command refuses, and changes nothing, when an object in `ids` is not an image, an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance, or the mask would hide every pixel of an image. @@ -10155,7 +10157,9 @@ profile and resolution). PDF's color key masking hides chosen colors of an image the images show their own pixels again. `addon.image.applyColorMask` makes each mask part of its image: the masked pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is taken off, in one undo step. A mask that hides nothing is only taken off, and the image keeps its pixels and its link. -Images without a mask are left as they are, and an object in `ids` that is not an image is refused. Both commands are +Images without a mask are left as they are, and an object in `ids` that is not an image is refused. Apply Color Mask +also refuses, and changes nothing, when a mask made again for new pixels would give a grayscale image colorized with a +process color an alpha channel. Both commands are enabled only when a selected image has a mask. → `{changed, ids}` ```json