From 0779da299b90054c64622da88fbb46cd062c4b1a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:53:57 -0800 Subject: [PATCH] Image Tools: Color Mask hides chosen colors of an image while its pixels stay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Image Tools gains Color Mask… (`addon.image.colorMask`), Remove Color Mask and Apply Color Mask. Color Mask hides colors of the selected images without changing their pixels. It matches colors as Remove Background does: the edge color, the corner color or picked colors, with Contiguous, Match, Tolerance and Softness. The mask is kept with the image object and saved with the document: its settings, and a PNG of the image's pixels with the hidden colors made clear, at the source's depth with its color profile and resolution. It is a VectorCraft addition. - The canvas (clip groups and white-shape opacity masks included), printing and separations, PDF (soft masks of CMYK images included), SVG, EPS, the raster formats, the legacy .ai and .ai editing data, EMF/WMF, DXF and the flattener draw the masked pixels. A linked image stays linked, and an output that would refer to its file writes the masked pixels instead. - Copy and paste, placing a document and placed documents carry the masked pixels with the image, under the names they are given there. - The dialog opens on the selected image's mask settings, with a Preview. Without settings, the command returns them and changes nothing. - Setting, removing and applying a mask are each one undo step, and no step is added when nothing changes. Apply makes the masked pixels the image's own and embeds a linked image. A mask that hides nothing is only taken off, and the image keeps its pixels and its link. - When an image's own pixels are replaced (Update Link, Relink, Crop Image, a recolor, a linked file read again on open), its mask is made again for the new pixels, in every art tree and in opacity-mask art. Crop Image cuts the image's own pixels. When the new pixels can't be masked, the mask keeps its settings and hides nothing. - Objects in `ids` that are not images, CMYK images, linked images that show their preview, images past the decode allowance, masks that would hide every pixel, and grayscale images colorized with a process color are refused. The Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels. - An image is selected by a click anywhere in its frame, as before. Remove Background now also refuses a grayscale image colorized with a process color. Its result has an alpha channel, and a process color is refused on a grayscale image with transparency. Docs: docs/mcp.md (Image Tools add-on › Color Mask), docs/target-app-parity.md. --- crates/cad/src/scene.rs | 2 +- crates/doc/src/color_mask.rs | 175 +++++ crates/doc/src/lib.rs | 1 + crates/doc/src/mem.rs | 4 + crates/doc/src/node/extras.rs | 4 + crates/doc/src/node/wire.rs | 5 + crates/doc/src/placed_document.rs | 31 +- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/addons.rs | 2 +- crates/engine/src/cmd/addons/image.rs | 184 +++-- crates/engine/src/cmd/addons/image/mask.rs | 424 ++++++++++ .../engine/src/cmd/addons/image/mask/tests.rs | 728 ++++++++++++++++++ crates/engine/src/cmd/addons/image/tests.rs | 2 +- crates/engine/src/cmd/clipboard/resources.rs | 8 + crates/engine/src/cmd/flatten/analysis.rs | 5 +- crates/engine/src/cmd/flatten/options.rs | 3 +- crates/engine/src/cmd/links.rs | 2 + crates/engine/src/cmd/menucmds.rs | 5 + crates/engine/src/cmd/paint.rs | 6 +- crates/engine/src/cmd/place/adopt.rs | 6 + crates/engine/src/cmd/plugin_call/reviewed.rs | 3 + crates/engine/src/session/edit.rs | 2 + crates/eps/src/ai_editing/writer.rs | 2 +- crates/eps/src/legacy_ai.rs | 2 +- crates/eps/src/legacy_ai/writer.rs | 3 +- crates/eps/src/scene.rs | 2 + crates/metafile/src/scene.rs | 2 +- crates/pdf/src/export/images.rs | 3 + crates/pdf/src/export/nodes.rs | 7 +- crates/pdf/src/print/plates.rs | 5 + crates/pdf/src/tests/cmykimages.rs | 41 +- crates/pdf/src/tests/colorize.rs | 34 + crates/render/src/renderer/images.rs | 2 +- crates/render/src/renderer/nodes.rs | 9 +- crates/svg/src/export/nodes.rs | 2 + crates/svg/src/export/paint.rs | 6 +- crates/ui-egui/src/addons/image.rs | 28 +- crates/ui-egui/src/addons/image/tests.rs | 68 +- crates/ui-egui/src/dialogs/command.rs | 14 +- crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hi.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ka.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/menus/invoke.rs | 3 +- docs/mcp.md | 60 +- docs/target-app-parity.md | 6 +- 69 files changed, 1979 insertions(+), 120 deletions(-) create mode 100644 crates/doc/src/color_mask.rs create mode 100644 crates/engine/src/cmd/addons/image/mask.rs create mode 100644 crates/engine/src/cmd/addons/image/mask/tests.rs diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..de529aa325 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -660,7 +660,7 @@ impl<'a> Scene<'a> { self.shape(n, &bp, *rule); } NodeKind::Text(t) => self.text(n, t), - NodeKind::Image(im) => self.image(im), + NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im)), NodeKind::SymbolInstance { symbol, xf } => { let doc = self.doc; if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { diff --git a/crates/doc/src/color_mask.rs b/crates/doc/src/color_mask.rs new file mode 100644 index 0000000000..52673d526b --- /dev/null +++ b/crates/doc/src/color_mask.rs @@ -0,0 +1,175 @@ +//! Image Tools › Color Mask: colors of an image object that are hidden while its pixels stay as +//! they are. +//! +//! A masked image is an ordinary image ([`NodeKind::Image`]) with a [`ColorMask`] in its extras +//! ([`crate::node::NodeExtras::color_mask`]). The record holds the mask's settings and the key of +//! the image's pixels with the hidden colors made clear ([`ColorMask::key`]), made by the engine +//! from the pixels named by [`ColorMask::from`]. The image's own key keeps naming its own pixels, +//! so removing the mask gives them back. While the image's key is [`ColorMask::from`], it is drawn +//! and output with the masked pixels ([`Document::shown_image`]); when another key replaced it +//! (a link updated, the image cropped), it shows its own pixels until the engine makes the masked +//! pixels again. +//! +//! The concept is that of PDF's color key masking (ISO 32000-1, 8.9.6.4): samples in the chosen +//! colors are not painted and what is behind the image shows through. Which colors match, and how +//! near ones fade out, are VectorCraft's own rules. + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +use crate::{Document, ImageObject, Node, NodeKind}; + +/// Where a color mask's colors come from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MaskBackground { + /// The most common color along the image's edges. + #[default] + Edges, + /// The average of the image's corner pixels that are not clear. + Corners, + /// The colors in [`ColorMask::colors`]. + Colors, +} + +/// How near a color must be to a masked one. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MaskMatch { + /// The RGB distance, as a percentage of the largest one. + #[default] + Distance, + /// The largest difference on one of red, green and blue, as a percentage of a channel's range. + Channels, +} + +/// The colors an image object hides (see the module docs). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ColorMask { + /// Key into [`Document::images`]: the pixels of [`Self::from`] with the hidden colors made + /// clear. Named `key` in the file, so the file keeps those pixels with the document. + pub key: String, + /// The image's own key when the masked pixels were made. + pub from: String, + #[serde(default)] + pub background: MaskBackground, + /// The hidden colors (8-bit RGB) with [`MaskBackground::Colors`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub colors: Vec<[u8; 3]>, + /// Only the matching pixels that reach in from an edge are hidden. + #[serde(default)] + pub contiguous: bool, + #[serde(default, rename = "match")] + pub measure: MaskMatch, + /// How near a color must be to be hidden, 0 to 100 (see [`MaskMatch`]). + #[serde(default)] + pub tolerance: f64, + /// The band past the tolerance over which pixels fade from hidden to shown, 0 to 100. + #[serde(default)] + pub softness: f64, +} + +impl ColorMask { + /// Whether the masked pixels were made from the pixels of `im`. + pub fn made_for(&self, im: &ImageObject) -> bool { + im.pixels.is_none() && self.from == im.key + } +} + +impl Document { + /// Image `im` of node `n` as it is drawn and output. With a color mask whose masked pixels were + /// made from the image's own pixels and are in this document ([`ColorMask::made_for`]): a copy + /// that shows those pixels and links to no file, so an output that refers to a linked file + /// writes the masked pixels instead. Otherwise, and for a mask that hides nothing (its masked + /// pixels are the image's own), `im` itself. + pub fn shown_image<'a>(&self, n: &Node, im: &'a ImageObject) -> Cow<'a, ImageObject> { + match n.extras().color_mask.as_deref() { + Some(m) if m.key != im.key && m.made_for(im) && self.images.contains_key(&m.key) => { + Cow::Owned(ImageObject { key: m.key.clone(), link: None, ..im.clone() }) + } + _ => Cow::Borrowed(im), + } + } + + /// The color mask of image node `n`, if it has one. + pub fn color_mask_of(n: &Node) -> Option<&ColorMask> { + matches!(n.kind, NodeKind::Image(_)).then(|| n.extras().color_mask.as_deref()).flatten() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vectorcraft_geom::Affine; + + use super::*; + use crate::{ImageBlob, NodeId}; + + fn image(key: &str) -> ImageObject { + ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None } + } + + fn mask(key: &str, from: &str) -> ColorMask { + ColorMask { + key: key.into(), + from: from.into(), + background: MaskBackground::Colors, + colors: vec![[255, 255, 255]], + contiguous: false, + measure: MaskMatch::Distance, + tolerance: 10.0, + softness: 0.0, + } + } + + #[test] + fn a_masked_image_shows_its_masked_pixels_while_they_were_made_from_its_own() { + let mut d = Document::new(100.0, 100.0); + d.images.insert("own".into(), ImageBlob::png(vec![1])); + d.images.insert("masked".into(), ImageBlob::png(vec![2])); + let mut n = Node::new(NodeId(5), NodeKind::Image(Box::new(image("own")))); + let NodeKind::Image(im) = &n.kind else { unreachable_kind() }; + assert_eq!(d.shown_image(&n, im).key, "own", "no mask: its own pixels"); + n.extras_mut().color_mask = Some(Box::new(mask("masked", "own"))); + let NodeKind::Image(im) = &n.kind else { unreachable_kind() }; + assert_eq!(d.shown_image(&n, im).key, "masked"); + // Linked: the shown copy links to nothing, so outputs write the masked pixels. + let mut linked = (**im).clone(); + linked.link = Some(crate::LinkInfo::new("/a.png")); + assert_eq!(d.shown_image(&n, &linked).link, None); + // Made from other pixels: its own until they are made again. + assert_eq!(d.shown_image(&n, &image("other")).key, "other"); + // Masked pixels the document doesn't have: its own. + n.extras_mut().color_mask = Some(Box::new(mask("gone", "own"))); + let NodeKind::Image(im) = &n.kind else { unreachable_kind() }; + assert_eq!(d.shown_image(&n, im).key, "own"); + // Pixels an image holds itself are drawn as they are. + let own = ImageObject { pixels: Some(Arc::new(ImageBlob::png(vec![3]))), ..image("own") }; + n.extras_mut().color_mask = Some(Box::new(mask("masked", "own"))); + assert_eq!(d.shown_image(&n, &own).key, "own"); + // A mask that hides nothing (its masked pixels are the image's own): the image itself, so + // a linked image stays linked in outputs. + n.extras_mut().color_mask = Some(Box::new(mask("own", "own"))); + assert_eq!(d.shown_image(&n, &linked).link, linked.link); + } + + #[test] + fn a_mask_saves_and_reads_back_with_its_node() { + let mut n = Node::new(NodeId(5), NodeKind::Image(Box::new(image("own")))); + n.extras_mut().color_mask = Some(Box::new(mask("masked", "own"))); + let json = serde_json::to_value(&n).unwrap(); + assert_eq!(json["color_mask"]["key"], "masked", "the masked pixels' key is a `key` member: {json}"); + assert_eq!(json["color_mask"]["match"], "distance"); + let back: Node = serde_json::from_value(json).unwrap(); + assert_eq!(back, n); + // A node without one writes none. + let plain = Node::new(NodeId(6), NodeKind::Image(Box::new(image("own")))); + assert!(serde_json::to_value(&plain).unwrap().get("color_mask").is_none()); + } + + fn unreachable_kind() -> ! { + panic!("an image node") + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..7e7b8411c8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ pub mod blend; pub mod cancel; pub mod clipnest; pub mod cmyk; +pub mod color_mask; pub mod compound_shape; pub mod corners; pub mod depth; diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 12d233ba37..7ff6cdeea2 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -261,6 +261,10 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet) -> u b += ALLOC + size_of::(); b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective); b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize); + b += e + .color_mask + .as_deref() + .map_or(0, |m| ALLOC + size_of::() + m.key.capacity() + m.from.capacity() + m.colors.capacity() * 3); b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::() + path_bytes(a)); if e.intertwine_tiers.capacity() > 0 { diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 6865ff7e6c..7b130bf395 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -72,6 +72,9 @@ pub struct NodeExtras { /// Transform panel › Align to Pixel Grid: the path's or compound path's horizontal and vertical /// segments stay on the pixel grid when it is transformed. pub pixel_aligned: bool, + /// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are + /// ([`crate::color_mask`]). + pub color_mask: Option>, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -99,6 +102,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { symbol_overrides: Vec::new(), envelope_edit_margin: None, pixel_aligned: false, + color_mask: None, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 2722e7bee0..34d8e99c53 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -55,6 +55,7 @@ impl Serialize for Node { e.symbol_overrides.is_empty(), e.envelope_edit_margin.is_none(), is_default(&e.pixel_aligned), + e.color_mask.is_none(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -98,6 +99,7 @@ impl Serialize for Node { field!("symbol_overrides", &e.symbol_overrides); field!("envelope_edit_margin", &e.envelope_edit_margin); field!("pixel_aligned", &e.pixel_aligned); + field!("color_mask", &e.color_mask); st.end() } } @@ -241,6 +243,8 @@ pub(super) struct NodeWire { envelope_edit_margin: Option<[f32; 4]>, #[serde(default)] pixel_aligned: bool, + #[serde(default)] + color_mask: Option>, } impl From for Node { @@ -269,6 +273,7 @@ impl From for Node { symbol_overrides: w.symbol_overrides, envelope_edit_margin: w.envelope_edit_margin, pixel_aligned: w.pixel_aligned, + color_mask: w.color_mask, }; Node { id: w.id, diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 33eccb178a..86f7f6134c 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -517,7 +517,12 @@ fn rename(n: &mut Node, prefix: &str) { paint(&mut r.style.stroke); } } - NodeKind::Image(im) => im.key = format!("{prefix}{}", im.key), + NodeKind::Image(im) => { + im.key = format!("{prefix}{}", im.key); + if let Some(m) = n.extras.0.as_deref_mut().and_then(|e| e.color_mask.as_deref_mut()) { + (m.key, m.from) = (format!("{prefix}{}", m.key), format!("{prefix}{}", m.from)); + } + } NodeKind::PlacedDocument(p) => p.key = format!("{prefix}{}", p.key), NodeKind::SymbolInstance { symbol, .. } => *symbol = format!("{prefix}{symbol}"), _ => {} @@ -681,6 +686,30 @@ mod tests { Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0))) } + /// A color-masked image in a placed document's art keeps its masked pixels under the prefixed + /// names its resources get. + #[test] + fn a_color_mask_takes_the_prefixed_image_names() { + let im = + ImageObject { key: "own".into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(NodeId(7), NodeKind::Image(Box::new(im))); + n.extras_mut().color_mask = Some(Box::new(crate::color_mask::ColorMask { + key: "masked".into(), + from: "own".into(), + background: Default::default(), + colors: vec![], + contiguous: true, + measure: Default::default(), + tolerance: 10.0, + softness: 10.0, + })); + rename(&mut n, "p:"); + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + let m = Document::color_mask_of(&n).unwrap(); + assert_eq!((im.key.as_str(), m.key.as_str(), m.from.as_str()), ("p:own", "p:masked", "p:own")); + assert!(m.made_for(im)); + } + #[test] fn round_trips_through_json() { let n = Node::new(NodeId(3), NodeKind::PlacedDocument(Box::new(placed("doc1-1")))); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 465025302f..c6f9941c89 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -766,7 +766,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; -pub(crate) use addons::DecodedImages; +pub(crate) use addons::{DecodedImages, refresh_color_masks}; pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 0815404835..66c67651a3 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -14,7 +14,7 @@ mod select; mod shapes; mod typetools; -pub(crate) use image::DecodedImages; +pub(crate) use image::{DecodedImages, refresh_color_masks}; use super::CommandSpec; diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index b2a9d74f83..d36b80cbb1 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -2,12 +2,16 @@ //! selected images transparent in their own pixels, as one undo step. The background is the most //! common color along an image's edges, the average of its four corners, or colors the caller //! picks. It is cleared where it reaches in from an edge, or wherever it is. The work is done here, -//! on the pixels; nothing is sent anywhere. It is a VectorCraft addition. Its submenu (Object › -//! Plug-ins › Image Tools) and dialog are the UI's. +//! on the pixels; nothing is sent anywhere. Color Mask ([`mask`]) hides colors matched the same +//! way while the image keeps its pixels. They are VectorCraft additions. Its submenu (Object › +//! Plug-ins › Image Tools) and dialogs are the UI's. +mod mask; #[cfg(test)] mod tests; +pub(crate) use mask::refresh as refresh_color_masks; + use std::collections::{HashMap, HashSet}; use std::io::Cursor; use std::sync::{Arc, Weak}; @@ -15,11 +19,12 @@ use std::sync::{Arc, Weak}; use image::{DynamicImage, ImageBuffer, LumaA, Rgba}; use serde_json::{Value, json}; use vectorcraft_color::Color; -use vectorcraft_doc::{ImageBlob, NodeId, NodeKind}; +use vectorcraft_doc::{ImageBlob, ImageObject, NodeId, NodeKind}; use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; +use super::super::paint::{check_colorized, check_colorized_kept}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -34,15 +39,17 @@ const MAX_COLORS: usize = 16; const WORK_BYTES: u64 = 10; pub fn specs() -> Vec { - vec![cmd!( + let mut v = vec![cmd!( "addon.image.removeBackground", "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background - )] + )]; + v.extend(mask::specs()); + v } /// Where the background's colors come from. @@ -78,50 +85,50 @@ pub(crate) struct Settings { } impl Settings { - /// The settings `p` gives, each checked. - fn read(p: &Value) -> Result { + /// The settings `p` gives to command `cmd`, each checked. + fn read(p: &Value, cmd: &str) -> Result { let percent = |key: &str| -> Result { - let v = number_or(p, key, 10.0, C)?; - if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(C, format!("`{key}` must be from 0 to 100"))) } + let v = number_or(p, key, 10.0, cmd)?; + if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from 0 to 100"))) } }; let (tolerance, softness) = (percent("tolerance")?, percent("softness")?); let colors = match p.get("colors") { None | Some(Value::Null) => None, - Some(v) => Some(colors(v)?), + Some(v) => Some(colors(v, cmd)?), }; - let background = match text_param(p, "background", C)? { + let background = match text_param(p, "background", cmd)? { None | Some("edges") => Background::Edges, Some("corners") => Background::Corners, - Some("colors") => Background::Colors(colors.ok_or_else(colors_error)?), - Some(_) => return Err(bad(C, "`background` must be edges, corners or colors")), + Some("colors") => Background::Colors(colors.ok_or_else(|| colors_error(cmd))?), + Some(_) => return Err(bad(cmd, "`background` must be edges, corners or colors")), }; - let measure = match text_param(p, "match", C)? { + let measure = match text_param(p, "match", cmd)? { None | Some("distance") => Match::Distance, Some("channels") => Match::Channels, - Some(_) => return Err(bad(C, "`match` must be distance or channels")), + Some(_) => return Err(bad(cmd, "`match` must be distance or channels")), }; - let contiguous = switch_or(p, "contiguous", true, C)?; + let contiguous = switch_or(p, "contiguous", true, cmd)?; Ok(Self { background, contiguous, measure, tolerance, softness }) } } -fn colors_error() -> crate::EngineError { - bad(C, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue") +fn colors_error(cmd: &str) -> crate::EngineError { + bad(cmd, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue") } -/// The colors `v` lists: an array of strings, or one string of them split by commas or spaces. -/// Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused. -fn colors(v: &Value) -> Result> { +/// The colors `v` lists to command `cmd`: an array of strings, or one string of them split by +/// commas or spaces. Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused. +fn colors(v: &Value, cmd: &str) -> Result> { // One past the most is enough to tell a list that is too long. let texts: Vec<&str> = match v { Value::String(s) => s.split([',', ' ', '\t', '\n']).filter(|t| !t.is_empty()).take(MAX_COLORS + 1).collect(), - Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::>().ok_or_else(colors_error)?, - _ => return Err(colors_error()), + Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::>().ok_or_else(|| colors_error(cmd))?, + _ => return Err(colors_error(cmd)), }; if texts.is_empty() || texts.len() > MAX_COLORS { - return Err(colors_error()); + return Err(colors_error(cmd)); } - texts.into_iter().map(|t| rgb8(t).ok_or_else(colors_error)).collect() + texts.into_iter().map(|t| rgb8(t).ok_or_else(|| colors_error(cmd))).collect() } /// The 8-bit RGB of the color `t` writes. @@ -134,7 +141,7 @@ fn rgb8(t: &str) -> Option<[u8; 3]> { } fn remove_background(s: &mut Session, p: &Value) -> Result { - let settings = Settings::read(p)?; + let settings = Settings::read(p, C)?; // A preview step keeps the pixels it decodes for the next step; other runs keep none. let mut kept = std::mem::take(&mut s.decoded_images); let previewing = s.previewing; @@ -144,6 +151,7 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { let ids: Vec = changes.iter().map(|(id, ..)| id.0).collect(); let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum(); if !changes.is_empty() { + let colorized_ok = check_colorized(&s.doc()?.doc).is_ok(); s.edit("Remove Background", |d, _| { for (id, out, xf) in changes { let key = out.blob.content_key(); @@ -154,7 +162,8 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { im.link = None; } } - Ok(()) + // A grayscale image colorized with a process color can't take an alpha channel. + check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) })?; } Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear })) @@ -164,35 +173,7 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { /// cleared as `settings` say and the transform that keeps its place and size. Decoded pixels are /// taken from and kept in `kept` when it is given. fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { - let st = s.doc()?; - let ids: Vec = if p.get("ids").is_some_and(|v| !v.is_null()) { - // Each image once, however often `ids` names it. - let mut given = HashSet::new(); - let ids: Vec = checked_ids_param(s, p, "ids", C)?.into_iter().filter(|id| given.insert(*id)).collect(); - if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { - return Err(bad(C, format!("object {} is not an image", id.0))); - } - ids - } else { - st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect() - }; - if ids.is_empty() { - return Err(bad(C, "select an image")); - } - // Every image is checked before any is decoded. - let mut images = vec![]; - for id in ids { - let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; - let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; - // The preview stands in for a file that hasn't been read, or that is kept at its preview. - if im.link.is_some() && blob.is_proxy() { - return Err(bad(C, "the linked image shows its preview: embed or relink it first")); - } - if blob.is_cmyk() { - return Err(bad(C, "a CMYK image has no transparency to set")); - } - images.push((id, im, blob)); - } + let images = images(s, p, C)?; // Each image's pixels once, however many objects show them. let mut cleared: HashMap<&str, Option> = HashMap::new(); let mut changes = vec![]; @@ -200,7 +181,7 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De let out = match cleared.get(im.key.as_str()) { Some(out) => out.clone(), None => { - let out = clear(blob, settings, kept.as_deref_mut())?; + let out = clear(blob, settings, kept.as_deref_mut(), C)?; cleared.insert(&im.key, out.clone()); out } @@ -218,6 +199,58 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De Ok(changes) } +/// The image objects command `cmd` acts on (`ids`, each once, or the selected images), each with +/// its pixels, all checked before any is decoded: an object that is not an image, missing pixels, +/// a linked image that shows its preview and a CMYK image are errors, as is no image at all. +fn images<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result> { + let st = s.doc()?; + let mut images = vec![]; + for id in targets(s, p, cmd)? { + let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(cmd, "the image's pixels are missing"))?; + // The preview stands in for a file that hasn't been read, or that is kept at its preview. + if im.link.is_some() && blob.is_proxy() { + return Err(bad(cmd, "the linked image shows its preview: embed or relink it first")); + } + if blob.is_cmyk() { + return Err(cmyk_refusal(cmd)); + } + images.push((id, &**im, blob)); + } + Ok(images) +} + +/// The image objects command `cmd` acts on: `ids`, each once, or the selected images. An object in +/// `ids` that is not an image is an error, as is no image at all. +fn targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let st = s.doc()?; + let ids: Vec = if p.get("ids").is_some_and(|v| !v.is_null()) { + // Each image once, however often `ids` names it. + let mut given = HashSet::new(); + let ids: Vec = checked_ids_param(s, p, "ids", cmd)?.into_iter().filter(|id| given.insert(*id)).collect(); + if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { + return Err(bad(cmd, format!("object {} is not an image", id.0))); + } + ids + } else { + st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect() + }; + if ids.is_empty() { + return Err(bad(cmd, "select an image")); + } + Ok(ids) +} + +/// Whether `cmd` is one of Color Mask's commands, which hide pixels instead of clearing them. +fn masks(cmd: &str) -> bool { + matches!(cmd, mask::COLOR_MASK | mask::REMOVE_COLOR_MASK | mask::APPLY_COLOR_MASK) +} + +/// Command `cmd`'s refusal of a CMYK image. +fn cmyk_refusal(cmd: &str) -> crate::EngineError { + if masks(cmd) { bad(cmd, "a CMYK image can't take a color mask") } else { bad(cmd, "a CMYK image has no transparency to set") } +} + /// An image with its background cleared: its PNG, its size in pixels, and how many of its pixels /// were made fully clear. #[derive(Clone, Debug)] @@ -345,16 +378,16 @@ impl Pixels { /// `blob` with its background cleared as `settings` say, or `None` when no pixel changes. A CMYK /// image, one that can't be decoded or is too large to change, and a result with no pixel left are -/// errors. The PNG keeps the source's ICC profile and resolution. The decoded pixels are taken -/// from and kept in `kept` when it is given. -pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { +/// errors of command `cmd`. The PNG keeps the source's ICC profile and resolution. The decoded +/// pixels are taken from and kept in `kept` when it is given. +pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>, cmd: &str) -> Result> { if blob.is_cmyk() { - return Err(bad(C, "a CMYK image has no transparency to set")); + return Err(cmyk_refusal(cmd)); } let found = kept.as_deref_mut().and_then(|k| k.get(&blob.bytes)); let (source, gray, icc) = match found { Some(found) => found, - None => decode(blob, kept)?, + None => decode(blob, kept, cmd)?, }; // The pixels kept for the next run stay as they are; ones not kept are worked on in place. let mut px = Arc::try_unwrap(source).unwrap_or_else(|kept| Pixels::clone(&kept)); @@ -366,7 +399,11 @@ pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut Pixels::Sixteen(img) => img.pixels().any(|p| p.0[3] > 0), }; if !left { - return Err(bad(C, "every pixel matches the background: no image would be left")); + return Err(if masks(cmd) { + bad(cmd, "the mask would hide every pixel") + } else { + bad(cmd, "every pixel matches the background: no image would be left") + }); } let out = match (px, gray) { (Pixels::Eight(img), false) => DynamicImage::ImageRgba8(img), @@ -382,9 +419,9 @@ pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut let mut encoder = image::codecs::png::PngEncoder::new(&mut png); if let Some(icc) = icc { use image::ImageEncoder as _; - encoder.set_icc_profile(icc).map_err(|e| bad(C, e.to_string()))?; + encoder.set_icc_profile(icc).map_err(|e| bad(cmd, e.to_string()))?; } - out.write_with_encoder(encoder).map_err(|e| bad(C, e.to_string()))?; + out.write_with_encoder(encoder).map_err(|e| bad(cmd, e.to_string()))?; // The resolution the file declares, which sizes the image at 100% when it is replaced or relinked. if let Some(r) = ppi::resolution(&blob.bytes) { png = ppi::with_png_resolution(&png, r); @@ -394,11 +431,12 @@ pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut /// `blob`'s pixels as straight RGBA, whether they were gray, and its ICC profile. An image that /// can't be decoded or is too large to change is an error. The pixels are kept in `kept`, when it -/// is given, if a copy of them fits the decode allowance beside the work. -fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc, bool, Option>)> { - let reader = - || image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(C, "the image's pixels can't be read")); - let mut decoder = reader()?.into_decoder().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; +/// is given, if a copy of them fits the decode allowance beside the work. Errors are command `cmd`'s. +fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>, cmd: &str) -> Result<(Arc, bool, Option>)> { + let reader = || { + image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(cmd, "the image's pixels can't be read")) + }; + let mut decoder = reader()?.into_decoder().map_err(|e| bad(cmd, format!("the image's pixels can't be read: {e}")))?; // The ICC profile the colors are in goes with them into the PNG. let (w, h, kind, icc) = { use image::ImageDecoder as _; @@ -411,13 +449,13 @@ fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc kind.channel_count(); let per_pixel = u64::from(kind.bytes_per_pixel()) + 2 * if deep { 8 } else { 4 } + WORK_BYTES; if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(per_pixel)).is_none_or(|n| n > MAX_RASTER_ALLOC) { - return Err(bad(C, format!("an image of {w} × {h} pixels is too large to change"))); + return Err(bad(cmd, format!("an image of {w} × {h} pixels is too large to change"))); } let mut reader = reader()?; let mut limits = image::Limits::default(); limits.max_alloc = Some(MAX_RASTER_ALLOC); reader.limits(limits); - let img = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + let img = reader.decode().map_err(|e| bad(cmd, format!("the image's pixels can't be read: {e}")))?; #[cfg(test)] DECODES.with(|c| c.set(c.get() + 1)); let gray = matches!(img.color(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16); diff --git a/crates/engine/src/cmd/addons/image/mask.rs b/crates/engine/src/cmd/addons/image/mask.rs new file mode 100644 index 0000000000..d6dc451f4f --- /dev/null +++ b/crates/engine/src/cmd/addons/image/mask.rs @@ -0,0 +1,424 @@ +//! Image Tools › Color Mask (`addon.image.colorMask`, `addon.image.removeColorMask`, +//! `addon.image.applyColorMask`): colors of the selected images are hidden while their pixels +//! stay as they are. The colors are matched as Remove Background matches them ([`super::clear`]); +//! the image keeps its own pixels, and the mask's settings and the masked pixels are kept with it +//! ([`vectorcraft_doc::color_mask`]), so the mask can be changed, removed, or applied to the +//! pixels later. When an image's own pixels are replaced (a link updated or relinked, the image +//! cropped or recolored), its mask is applied to the new pixels ([`refresh`]). + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +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::{CommandSpec, Result, Session, bad, cmd}; +use super::{Background, DecodedImages, MAX_COLORS, Match, Settings, clear, images, targets}; + +/// Color Mask's command. +pub const COLOR_MASK: &str = "addon.image.colorMask"; +/// Remove Color Mask's command. +pub const REMOVE_COLOR_MASK: &str = "addon.image.removeColorMask"; +/// Apply Color Mask's command. +pub const APPLY_COLOR_MASK: &str = "addon.image.applyColorMask"; + +/// The params that set a mask; with none of them, Color Mask only reports. +const SETTINGS: [&str; 6] = ["background", "colors", "contiguous", "match", "tolerance", "softness"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.image.colorMask", + "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}", + super::has_image, + color_mask + ), + cmd!( + "addon.image.removeColorMask", + "Remove Color Mask", + ["Object", "Plug-ins", "Image Tools"], + None, + "{ids?: [id…] (default: the selected images)} take the color mask off each image, as one undo step: the image shows its own pixels again; 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}", + has_color_mask, + remove_color_mask + ), + cmd!( + "addon.image.applyColorMask", + "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}", + has_color_mask, + apply_color_mask + ), + ] +} + +/// Enabled when a selected image has a color mask. +fn has_color_mask(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.selection.objects.iter().any(|id| st.doc.node(*id).and_then(Document::color_mask_of).is_some()) { + Ok(()) + } else { + Err("select an image with a color mask".into()) + } +} + +impl Settings { + /// The settings a mask was made with, `None` when a file gave ones out of range. + fn of(m: &ColorMask) -> Option { + let percent = |v: f64| v.is_finite() && (0.0..=100.0).contains(&v); + if !percent(m.tolerance) || !percent(m.softness) { + return None; + } + let background = match m.background { + MaskBackground::Edges => Background::Edges, + MaskBackground::Corners => Background::Corners, + MaskBackground::Colors if (1..=MAX_COLORS).contains(&m.colors.len()) => Background::Colors(m.colors.clone()), + MaskBackground::Colors => return None, + }; + let measure = match m.measure { + MaskMatch::Distance => Match::Distance, + MaskMatch::Channels => Match::Channels, + }; + Some(Self { background, contiguous: m.contiguous, measure, tolerance: m.tolerance, softness: m.softness }) + } + + /// A mask with these settings whose masked pixels are `key`, made from the pixels `from`. + fn mask(&self, key: String, from: String) -> ColorMask { + let (background, colors) = match &self.background { + Background::Edges => (MaskBackground::Edges, vec![]), + Background::Corners => (MaskBackground::Corners, vec![]), + Background::Colors(c) => (MaskBackground::Colors, c.clone()), + }; + let measure = match self.measure { + Match::Distance => MaskMatch::Distance, + Match::Channels => MaskMatch::Channels, + }; + ColorMask { key, from, background, colors, contiguous: self.contiguous, measure, tolerance: self.tolerance, softness: self.softness } + } +} + +/// The masked pixels of `blob` (`None`: no pixel is hidden, the image's own pixels show) as +/// `settings` say, with errors of command `cmd`. +fn masked(blob: &ImageBlob, settings: &Settings, kept: Option<&mut DecodedImages>, cmd: &str) -> Result> { + Ok(clear(blob, settings, kept, cmd)?.map(|out| (out.blob.content_key(), out.blob, out.cleared))) +} + +fn color_mask(s: &mut Session, p: &Value) -> Result { + if !SETTINGS.iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { + return report(s, p); + } + let settings = Settings::read(p, COLOR_MASK)?; + // A preview step keeps the pixels it decodes for the next step; other runs keep none. + let mut kept = std::mem::take(&mut s.decoded_images); + let previewing = s.previewing; + let found = mask_changes(s, p, &settings, previewing.then_some(&mut kept)); + s.decoded_images = kept; + let changes = found?; + 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 { + d.images.entry(mask.key.clone()).or_insert(blob); + } + if let Some(n) = d.node_mut(id) { + 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(json!({ "changed": ids.len(), "ids": ids, "hidden": hidden })) +} + +/// The images `p` names whose mask changes, each with its new mask, the masked pixels to add (when +/// the document doesn't have them) and how many pixels they hide. +type MaskChange = (NodeId, ColorMask, Option, u64); + +fn mask_changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + let st = s.doc()?; + // Each image's pixels once, however many objects show them. + let mut made: HashMap<&str, (String, Option, u64)> = HashMap::new(); + let mut out = vec![]; + for (id, im, blob) in images(s, p, COLOR_MASK)? { + let (key, pixels, hidden) = match made.get(im.key.as_str()) { + Some(m) => m.clone(), + None => { + let m = match masked(blob, settings, kept.as_deref_mut(), COLOR_MASK)? { + Some((key, blob, hidden)) => (key, Some(blob), hidden), + None => (im.key.clone(), None, 0), + }; + made.insert(&im.key, m.clone()); + m + } + }; + let mask = settings.mask(key, im.key.clone()); + if st.doc.node(id).and_then(Document::color_mask_of) == Some(&mask) { + continue; + } + let pixels = pixels.filter(|_| !st.doc.images.contains_key(&mask.key)); + out.push((id, mask, pixels, hidden)); + } + Ok(out) +} + +/// 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 { + let st = s.doc()?; + let first = targets(s, p, COLOR_MASK)?.first().and_then(|id| st.doc.node(*id)).and_then(Document::color_mask_of).cloned(); + let m = first.as_ref(); + let hex = |c: &[u8; 3]| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]); + let colors = + m.filter(|m| !m.colors.is_empty()).map_or_else(|| "#ffffff".to_string(), |m| m.colors.iter().map(hex).collect::>().join(", ")); + let name = |v: Value| v.as_str().map(str::to_string).unwrap_or_default(); + Ok(json!({ + "background": name(serde_json::to_value(m.map_or(MaskBackground::Edges, |m| m.background)).unwrap_or_default()), + "colors": colors, + "contiguous": m.is_none_or(|m| m.contiguous), + "match": name(serde_json::to_value(m.map_or(MaskMatch::Distance, |m| m.measure)).unwrap_or_default()), + "tolerance": m.map_or(10.0, |m| m.tolerance), + "softness": m.map_or(10.0, |m| m.softness), + "masked": m.is_some(), + })) +} + +/// The image objects `p` names ([`targets`]: `ids`, or the selected images) that have a color mask. +fn masked_images(s: &Session, p: &Value, cmd: &str) -> Result> { + let st = s.doc()?; + Ok(targets(s, p, cmd)?.into_iter().filter(|id| st.doc.node(*id).and_then(Document::color_mask_of).is_some()).collect()) +} + +fn remove_color_mask(s: &mut Session, p: &Value) -> Result { + let ids = masked_images(s, p, REMOVE_COLOR_MASK)?; + if !ids.is_empty() { + s.edit("Remove Color Mask", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.extras_mut().color_mask = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids.iter().map(|id| id.0).collect::>() })) +} + +fn apply_color_mask(s: &mut Session, p: &Value) -> Result { + let ids = masked_images(s, p, APPLY_COLOR_MASK)?; + // Each image's masked pixels, made again first when its own pixels changed since. A mask that + // hides nothing (its masked pixels are the image's own) is only taken off: the image keeps its + // pixels and its link. + let mut made = vec![]; + { + let st = s.doc()?; + for id in &ids { + let Some(n) = st.doc.node(*id) else { continue }; + let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) else { continue }; + let mask = match fresh(&st.doc, n, APPLY_COLOR_MASK) { + Some(Ok(fresh)) => fresh, + Some(Err(e)) => return Err(e), + None => (m.clone(), None), + }; + if mask.0.key == im.key { + 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); + made.push((*id, Some((mask, size)))); + } + } + if !made.is_empty() { + s.edit("Apply Color Mask", |d, _| { + for (id, applied) in made { + let Some(n) = d.node_mut(id) else { continue }; + n.extras_mut().color_mask = None; + let Some(((mask, blob), (w, h))) = applied else { continue }; + if let Some(blob) = blob { + d.images.entry(mask.key.clone()).or_insert(blob); + } + let Some(n) = d.node_mut(id) else { continue }; + if let NodeKind::Image(im) = &mut n.kind { + // Masked pixels of another size than the object's keep its place and size. + if (w, h) != (im.width, im.height) { + im.xf *= Affine::scale_non_uniform(f64::from(im.width) / f64::from(w), f64::from(im.height) / f64::from(h)); + (im.width, im.height) = (w, h); + } + im.key = mask.key; + im.link = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids.iter().map(|id| id.0).collect::>() })) +} + +/// The size in pixels of masked pixels `blob` (from its header), or the object's when it can't be +/// read. +fn mask_size(blob: Option<&ImageBlob>, width: u32, height: u32) -> (u32, u32) { + blob.and_then(|b| image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()) + .filter(|(w, h)| *w > 0 && *h > 0) + .unwrap_or((width, height)) +} + +/// The mask of image node `n` made again for the image's own pixels, with the masked pixels when +/// `doc` doesn't have them: `None` when the mask is up to date (or `n` has none), an error when the +/// pixels can't be masked (missing, CMYK, a linked preview, too large, every pixel hidden, or +/// settings a file gave out of range). +fn fresh(doc: &Document, n: &Node, cmd: &str) -> Option)>> { + let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) else { return None }; + if !stale_mask(doc, n) { + return None; + } + let made = || -> Result<(ColorMask, Option)> { + let settings = Settings::of(m).ok_or_else(|| bad(cmd, "the color mask's settings are out of range"))?; + let blob = doc.images.get(&im.key).ok_or_else(|| bad(cmd, "the image's pixels are missing"))?; + if im.link.is_some() && blob.is_proxy() { + return Err(bad(cmd, "the linked image shows its preview: embed or relink it first")); + } + Ok(match masked(blob, &settings, None, cmd)? { + Some((key, blob, _)) => { + let blob = (!doc.images.contains_key(&key)).then_some(blob); + (settings.mask(key, im.key.clone()), blob) + } + None => (settings.mask(im.key.clone(), im.key.clone()), None), + }) + }; + Some(made()) +} + +/// After an edit from `before` (`None`: everything is new, as after a document's links are read): +/// the color masks of images in `doc`'s art ([`Document::art_roots`]: layers, symbols, graph +/// designs, patterns and the original of the pattern being edited, opacity mask art included) +/// whose own pixels were replaced are made again for the new pixels ([`Refresh::made`]). Only the +/// art that changed since `before` is looked at. +pub(crate) fn refresh(before: Option<&Document>, doc: &mut Document) { + let old: Vec<&Arc> = before.map(|b| b.art_roots().collect()).unwrap_or_default(); + let mut pass = Refresh { doc: &*doc, made: HashMap::new(), blobs: vec![] }; + let mut fixed = vec![]; + for (i, root) in doc.art_roots().enumerate() { + let counterpart = old.get(i).copied().filter(|o| o.id == root.id); + if stale_in(doc, root, counterpart) { + let mut root = root.clone(); + pass.fix(Arc::make_mut(&mut root)); + fixed.push((i, root)); + } + } + let blobs = pass.blobs; + if fixed.is_empty() { + return; + } + for (key, blob) in blobs { + doc.images.entry(key).or_insert(blob); + } + let mut fixed = fixed.into_iter().peekable(); + for (i, root) in doc.art_roots_mut().enumerate() { + if fixed.peek().is_some_and(|(at, _)| *at == i) + && let Some((_, new)) = fixed.next() + { + *root = new; + } + } +} + +/// Whether `n`, or anything in it (opacity-mask art included), is an image whose mask is out of +/// date ([`stale_mask`]). What is the same as in `old`, `n`'s counterpart before an edit, is not +/// looked at. +fn stale_in(doc: &Document, n: &Arc, old: Option<&Arc>) -> bool { + if old.is_some_and(|o| Arc::ptr_eq(o, n)) { + return false; + } + if stale_mask(doc, n) { + return true; + } + let old_mask = old.and_then(|o| o.extras().mask.as_ref()).map(|m| &m.art); + if n.extras().mask.as_ref().is_some_and(|m| stale_in(doc, &m.art, old_mask)) { + return true; + } + let Some(children) = n.children() else { return false }; + let before = old.and_then(|o| o.children()).map_or(&[][..], Vec::as_slice); + let mut by_id: Option>> = None; + children.iter().enumerate().any(|(i, c)| { + let b = match before.get(i) { + Some(b) if b.id == c.id => Some(b), + _ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&c.id).copied(), + }; + stale_in(doc, c, b) + }) +} + +/// Whether `n` is an image whose color mask was not made from its own pixels (or whose masked +/// pixels `doc` doesn't have). A mask that hides nothing (its masked pixels are the image's own) +/// is up to date while the image's key stays the same, even when its pixels are missing. +fn stale_mask(doc: &Document, n: &Node) -> bool { + matches!((&n.kind, Document::color_mask_of(n)), (NodeKind::Image(im), Some(m)) + 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. +struct Refresh<'a> { + doc: &'a Document, + made: HashMap<(String, String), ColorMask>, + blobs: Vec<(String, ImageBlob)>, +} + +impl Refresh<'_> { + /// Make the masks out of date in `n` (a copy of the document's art) again. + fn fix(&mut self, n: &mut Node) { + if let Some(mask) = self.made(n) { + n.extras_mut().color_mask = Some(Box::new(mask)); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && stale_in(self.doc, &m.art, None) + { + self.fix(Arc::make_mut(&mut m.art)); + } + for c in n.children_mut().into_iter().flatten() { + if stale_in(self.doc, c, None) { + self.fix(Arc::make_mut(c)); + } + } + } + + /// 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. + 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:?}")); + if let Some(made) = self.made.get(&at) { + return Some(made.clone()); + } + let mask = match fresh(self.doc, n, COLOR_MASK)? { + 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() }, + }; + 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 new file mode 100644 index 0000000000..1366a3dcf5 --- /dev/null +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -0,0 +1,728 @@ +use std::io::Cursor; + +use image::DynamicImage; +use serde_json::{Value, json}; +use vectorcraft_doc::color_mask::{ColorMask, MaskBackground}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, NodeId, NodeKind}; +use vectorcraft_geom::Rect; + +use super::{APPLY_COLOR_MASK, COLOR_MASK, REMOVE_COLOR_MASK}; +use crate::Session; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [200, 0, 0, 255]; + +fn png(img: DynamicImage) -> Vec { + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// 12 × 10 pixels: white, with a red block from (3, 3) to (9, 7) that holds one white pixel at +/// (6, 5). +fn framed() -> Vec { + png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(12, 10, |x, y| { + let block = (3..9).contains(&x) && (3..7).contains(&y); + image::Rgba(if block && (x, y) != (6, 5) { RED } else { WHITE }) + }))) +} + +/// Hide white wherever it is, exactly. +fn hide_white() -> Value { + json!({"background": "colors", "colors": "#ffffff", "contiguous": false, "tolerance": 0, "softness": 0}) +} + +/// A session with `bytes` opened as a document, its image selected. +fn open(bytes: &[u8]) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + (s, id) +} + +fn doc(s: &Session) -> &Document { + &s.doc().unwrap().doc +} + +fn image_of(d: &Document, id: NodeId) -> ImageObject { + let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!("an image") }; + (**im).clone() +} + +fn mask_of(d: &Document, id: NodeId) -> Option { + Document::color_mask_of(d.node(id).unwrap()).cloned() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The straight RGBA of pixel (`x`, `y`) of image `id` as `d` draws it, on nothing. +fn drawn(d: &Document, id: NodeId, x: u32, y: u32) -> [u8; 4] { + let im = image_of(d, id); + let at = im.xf * vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + let r = vectorcraft_render::Renderer::new().render_region(d, Rect::new(at.x - 0.5, at.y - 0.5, at.x + 0.5, at.y + 0.5), 1.0, false); + let px = r.to_straight(); + [px[0], px[1], px[2], px[3]] +} + +#[test] +fn the_mask_hides_the_chosen_colors_while_the_pixels_stay() { + let (mut s, id) = open(&framed()); + let before = (image_of(doc(&s), id), doc(&s).images[&image_of(doc(&s), id).key].bytes.clone(), undo_len(&s)); + assert_eq!(drawn(doc(&s), id, 0, 0), WHITE); + let r = s.execute(COLOR_MASK, &hide_white()).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [id.0], "hidden": 12 * 10 - 6 * 4 + 1})); + let d = doc(&s); + assert_eq!(image_of(d, id), before.0, "the image object is as it was"); + assert_eq!(d.images[&before.0.key].bytes, before.1, "its pixels are as they were"); + let m = mask_of(d, id).unwrap(); + assert_eq!((m.from.as_str(), m.background, m.contiguous), (before.0.key.as_str(), MaskBackground::Colors, false)); + assert_eq!(drawn(d, id, 0, 0)[3], 0, "white is hidden"); + assert_eq!(drawn(d, id, 6, 5)[3], 0, "inside the block too"); + assert_eq!(drawn(d, id, 4, 4), RED, "the red shows"); + assert_eq!(undo_len(&s), before.2 + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(mask_of(doc(&s), id), None); + assert_eq!(drawn(doc(&s), id, 0, 0), WHITE); +} + +#[test] +fn the_mask_is_edited_reported_and_removed() { + let (mut s, id) = open(&framed()); + let r = s.execute(COLOR_MASK, &json!({})).unwrap(); + assert_eq!( + r, + json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10.0, "softness": 10.0, "masked": false}) + ); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let steps = undo_len(&s); + // The same settings again: nothing changes. + assert_eq!(s.execute(COLOR_MASK, &hide_white()).unwrap()["changed"], 0); + assert_eq!(undo_len(&s), steps, "no undo step"); + // Contiguous: the white inside the block shows again. + let r = s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#ffffff", "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!((drawn(doc(&s), id, 0, 0)[3], drawn(doc(&s), id, 6, 5)), (0, WHITE)); + let r = s.execute(COLOR_MASK, &json!({})).unwrap(); + assert_eq!( + r, + json!({"background": "colors", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 0.0, "softness": 0.0, "masked": true}) + ); + let steps = undo_len(&s); + assert_eq!(s.execute(REMOVE_COLOR_MASK, &json!({})).unwrap(), json!({"changed": 1, "ids": [id.0]})); + assert_eq!(undo_len(&s), steps + 1); + assert_eq!((mask_of(doc(&s), id), drawn(doc(&s), id, 0, 0)), (None, WHITE)); + // Nothing left to remove: the command is not enabled, and adds no undo step. + let e = s.execute(REMOVE_COLOR_MASK, &json!({})).unwrap_err(); + assert!(e.to_string().contains("select an image with a color mask"), "{e}"); + assert_eq!(undo_len(&s), steps + 1); +} + +#[test] +fn applying_the_mask_makes_it_part_of_the_pixels() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let m = mask_of(doc(&s), id).unwrap(); + let steps = undo_len(&s); + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({})).unwrap(), json!({"changed": 1, "ids": [id.0]})); + let d = doc(&s); + assert_eq!((image_of(d, id).key, mask_of(d, id)), (m.key.clone(), None)); + let px = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(4, 4).0), (0, RED)); + assert_eq!(drawn(d, id, 0, 0)[3], 0); + assert_eq!(undo_len(&s), steps + 1); + assert!(s.execute(APPLY_COLOR_MASK, &json!({})).is_err(), "no mask left"); +} + +/// The bytes of the first `data:image/png;base64,` image in `svg`. +fn svg_image(svg: &str) -> DynamicImage { + let at = svg.find("data:image/png;base64,").expect("an embedded PNG") + "data:image/png;base64,".len(); + let end = at + svg[at..].find('"').unwrap(); + image::load_from_memory(&vectorcraft_format::base64_decode(&svg[at..end]).unwrap()).unwrap() +} + +#[test] +fn outputs_write_the_masked_pixels() { + let (mut s, _) = open(&framed()); + let export = + |s: &Session, format: &str| crate::cmd::fileio::encode_all(doc(s), format, &json!({"useArtboards": true})).unwrap().files.remove(0).1; + let (png0, svg0, pdf0, eps0) = (export(&s, "png"), export(&s, "svg"), export(&s, "pdf"), export(&s, "eps")); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let alpha = |bytes: &[u8]| image::load_from_memory(bytes).unwrap().to_rgba8().get_pixel(0, 0).0[3]; + assert_eq!((alpha(&png0), alpha(&export(&s, "png"))), (255, 0), "PNG"); + let svg_alpha = |bytes: &[u8]| svg_image(std::str::from_utf8(bytes).unwrap()).to_rgba8().get_pixel(0, 0).0[3]; + assert_eq!((svg_alpha(&svg0), svg_alpha(&export(&s, "svg"))), (255, 0), "SVG"); + let has = |bytes: &[u8], what: &[u8]| bytes.windows(what.len()).any(|w| w == what); + assert!(!has(&pdf0, b"/SMask") && has(&export(&s, "pdf"), b"/SMask"), "PDF: the image with an alpha channel"); + assert!(!has(&eps0, b"/MaskColor") && has(&export(&s, "eps"), b"/MaskColor"), "EPS: the hidden pixels masked out"); +} + +#[test] +fn the_mask_saves_and_opens_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let saved = vectorcraft_format::save_file(doc(&s)); + let back = vectorcraft_format::load(&saved).unwrap(); + assert_eq!(mask_of(&back, id), mask_of(doc(&s), id)); + let m = mask_of(&back, id).unwrap(); + assert_eq!(back.images[&m.key].bytes, doc(&s).images[&m.key].bytes, "the masked pixels are saved with it"); + assert_eq!(back.images[&m.from].bytes, doc(&s).images[&m.from].bytes, "and the image's own"); + assert_eq!(drawn(&back, id, 0, 0)[3], 0); +} + +#[test] +fn copy_and_paste_carry_the_mask() { + let (mut s, _) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + super::super::DECODES.with(|c| c.set(0)); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(super::super::DECODES.with(|c| c.get()), 0, "the masked pixels come along, so nothing is decoded to make them again"); + let id = s.doc().unwrap().selection.objects[0]; + let d = doc(&s); + let m = mask_of(d, id).expect("the pasted image keeps its mask"); + assert!(m.made_for(&image_of(d, id)) && d.images.contains_key(&m.key), "with its masked pixels"); + assert_eq!(drawn(d, id, 0, 0)[3], 0); + assert_eq!(drawn(d, id, 4, 4), RED); +} + +#[test] +fn new_pixels_take_the_mask_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + // Crop Image replaces the image's own pixels: the mask is made for the cropped ones. + let r = s.execute("object.cropImage", &json!({"rect": [0, 0, 6, 5]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap_or(id.0)); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!(m.from, im.key, "made for the cropped pixels"); + assert_eq!((drawn(d, id, 0, 0)[3], drawn(d, id, 4, 4)), (0, RED)); + // Pixels put in place by any edit (a link read again) take it too. + let blue = png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(6, 5, |x, _| image::Rgba(if x < 3 { WHITE } else { [0, 0, 200, 255] })))); + s.edit("Swap", |d, _| { + let blob = ImageBlob::png(blue); + 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); + assert_eq!(mask_of(d, id).unwrap().from, image_of(d, id).key); + assert_eq!((drawn(d, id, 0, 0)[3], drawn(d, id, 4, 0)), (0, [0, 0, 200, 255])); +} + +#[test] +fn a_document_read_with_new_pixels_takes_the_mask_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + let blob = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(4, 4, image::Rgba(WHITE))))); + let red = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, |x, _| image::Rgba(if x == 0 { WHITE } else { RED }))))); + let key = red.content_key(); + d.images.insert(key.clone(), red); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key.clone(); + } + assert_eq!(drawn(&d, id, 0, 0), WHITE, "until the mask is made again, the image's own pixels"); + super::refresh(None, &mut d); + assert_eq!(mask_of(&d, id).unwrap().from, key); + assert_eq!(drawn(&d, id, 0, 0)[3], 0); + // Pixels the mask would hide entirely: the mask stays, hiding nothing. + let all = blob.content_key(); + d.images.insert(all.clone(), blob); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = all.clone(); + } + super::refresh(None, &mut d); + let m = mask_of(&d, id).unwrap(); + assert_eq!((m.key.as_str(), m.from.as_str(), m.colors.as_slice()), (all.as_str(), all.as_str(), &[[255, 255, 255]][..])); + assert_eq!(drawn(&d, id, 0, 0), WHITE); +} + +/// A new document with a selected 12 × 10 image of `blob` under `key`, linked to `/nowhere/art.png`. +fn linked(key: &str, blob: ImageBlob) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let key = key.to_string(); + let id = s + .edit("Place", |d, sel| { + d.images.insert(key.clone(), blob); + let id = d.alloc_id(); + let im = ImageObject { + key, + width: 12, + height: 10, + xf: Default::default(), + link: Some(vectorcraft_doc::LinkInfo::new("/nowhere/art.png")), + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + (s, id) +} + +/// A linked image keeps its link; outputs that refer to linked files write its masked pixels, and +/// applying the mask embeds it. +#[test] +fn a_linked_image_stays_linked_and_outputs_its_masked_pixels() { + let (mut s, id) = linked("linked", ImageBlob::png(framed())); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let d = doc(&s); + assert_eq!((image_of(d, id).key.as_str(), image_of(d, id).link.map(|l| l.path)), ("linked", Some("/nowhere/art.png".to_string()))); + assert_eq!(drawn(d, id, 0, 0)[3], 0); + // Images written as files beside the SVG: the masked pixels are written, not a link to the + // linked file. + let r = s.execute("document.serialize", &json!({"format": "svg", "images": "link"})).unwrap(); + let text = r["text"].as_str().unwrap(); + assert!(!text.contains("/nowhere/art.png"), "{text}"); + assert_eq!(r["linked"].as_array().map(Vec::len), Some(1), "{r}"); + let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert_eq!(svg_image(r["text"].as_str().unwrap()).to_rgba8().get_pixel(0, 0).0[3], 0); + let m = mask_of(doc(&s), id).unwrap(); + s.execute(APPLY_COLOR_MASK, &json!({})).unwrap(); + let im = image_of(doc(&s), id); + assert_eq!((im.key, im.link), (m.key, None), "applied: the masked pixels, embedded"); +} + +/// A mask that hides nothing (no pixel matches) leaves a linked image linked in outputs, and +/// applying it only takes it off: the image keeps its pixels and its link. +#[test] +fn a_mask_that_hides_nothing_keeps_the_link() { + let (mut s, id) = linked("linked", ImageBlob::png(framed())); + let r = s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#0000ff", "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["hidden"].as_u64()), (Some(1), Some(0))); + assert_eq!(mask_of(doc(&s), id).unwrap().key, "linked", "the masked pixels are the image's own"); + let r = s.execute("document.serialize", &json!({"format": "svg", "images": "link"})).unwrap(); + assert!(r["text"].as_str().unwrap().contains("/nowhere/art.png"), "{r}"); + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({})).unwrap()["changed"], 1); + let im = image_of(doc(&s), id); + assert_eq!((im.key.as_str(), im.link.map(|l| l.path), mask_of(doc(&s), id)), ("linked", Some("/nowhere/art.png".to_string()), None)); +} + +/// Color Mask refuses a linked image that shows its preview and an image too large to change, +/// with its own command's errors. +#[test] +fn color_mask_refuses_previews_and_images_too_large() { + let preview = std::sync::Arc::new(framed()); + let (mut s, id) = linked("missing", ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(COLOR_MASK) && e.contains("the linked image shows its preview: embed or relink it first"), "{e}"); + assert_eq!(mask_of(doc(&s), id), None); + let (mut s, _) = open(&crate::tests_linkspanel::png_header(10_632, 10_631)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(COLOR_MASK) && e.contains("an image of 10632 × 10631 pixels is too large to change"), "{e}"); +} + +/// Crop Image off the pixel grid cuts the image's own pixels, so the hidden colors stay in them +/// and show again once the mask is removed. +#[test] +fn cropping_off_the_pixel_grid_keeps_the_hidden_colors_in_the_pixels() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.execute("object.cropImage", &json!({"rect": [0.5, 0.5, 6, 5]})).unwrap(); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!(m.from, im.key, "the mask is made for the cut pixels"); + let own = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert!(own.pixels().all(|p| p.0[3] == 255), "the cut pixels are the image's own, white included"); + assert_eq!(drawn(d, id, 0, 0)[3], 0, "white is hidden"); + s.execute(REMOVE_COLOR_MASK, &json!({"ids": [id.0]})).unwrap(); + assert_eq!(drawn(doc(&s), id, 0, 0)[3], 255); +} + +/// Apply Color Mask on a mask made for older pixels makes it again for the image's own pixels, +/// and masked pixels of another size than the object records keep its place and size. +#[test] +fn applying_a_mask_made_for_older_pixels_makes_it_again_and_keeps_the_size() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let before = doc(&s).node(id).unwrap().geometric_bounds().unwrap(); + // Pixels twice the size, put in place without an edit, so no refresh has run. + let big = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(24, 20, |x, y| { + let block = (6..18).contains(&x) && (6..14).contains(&y); + image::Rgba(if block { RED } else { WHITE }) + })))); + let key = big.content_key(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.images.insert(key.clone(), big); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key.clone(); + } + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({})).unwrap()["changed"], 1); + let d = doc(&s); + let im = image_of(d, id); + assert_ne!(im.key, key, "the masked pixels of the new pixels"); + assert_eq!((im.width, im.height, mask_of(d, id)), (24, 20, None)); + let after = d.node(id).unwrap().geometric_bounds().unwrap(); + assert!((after.width() - before.width()).abs() < 1e-9 && (after.height() - before.height()).abs() < 1e-9, "{before:?} {after:?}"); + let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(10, 10).0), (0, RED)); +} + +/// A 12 × 10 grayscale image without alpha: white, with a black block from (3, 3) to (9, 7). +fn gray_framed() -> Vec { + png(DynamicImage::ImageLuma8(image::GrayImage::from_fn(12, 10, |x, y| { + image::Luma([if (3..9).contains(&x) && (3..7).contains(&y) { 0 } else { 255 }]) + }))) +} + +/// Masked pixels have an alpha channel: a grayscale image colorized with a process color can't +/// take a mask (or have its background removed), and a masked one can't take a process color. +#[test] +fn a_process_color_does_not_colorize_masked_gray_pixels() { + 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(); + assert!(doc(&s).node(id).unwrap().extras().colorize.is_some(), "a gray image without alpha takes a process color"); + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + let e = s.execute(super::super::REMOVE_BACKGROUND, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); + // Black colorizes gray pixels with alpha: the mask is set, then a process color is refused. + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + assert_eq!(s.execute(COLOR_MASK, &hide_white()).unwrap()["changed"], 1); + let e = s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); +} + +/// 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] +fn masks_outside_the_layers_are_made_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + let masked = d.node(id).unwrap().clone(); + let red = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, |x, _| image::Rgba(if x == 0 { WHITE } else { RED }))))); + let key = red.content_key(); + d.images.insert(key.clone(), red); + let mut moved = masked.clone(); + if let NodeKind::Image(im) = &mut moved.kind { + im.key = key.clone(); + } + d.symbols.push(vectorcraft_doc::Symbol { name: "Logo".into(), art: std::sync::Arc::new(moved.clone()), dynamic: false }); + let holder_id = d.alloc_id(); + let mut holder = vectorcraft_doc::Node::new(holder_id, NodeKind::Group { children: vec![], clip: false }); + holder.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(moved, false))); + d.insert(d.default_layer(), 0, holder).unwrap(); + super::refresh(None, &mut d); + let from = |n: &vectorcraft_doc::Node| Document::color_mask_of(n).unwrap().from.clone(); + assert_eq!(from(&d.symbols.last().unwrap().art), key, "in the symbol's art"); + let art = &d.node(holder_id).unwrap().extras().mask.as_ref().unwrap().art; + assert_eq!(from(art), key, "in the opacity mask's art"); + let m = Document::color_mask_of(art).unwrap(); + let px = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(1, 0).0), (0, RED)); +} + +/// `ids` stand in for the selection: an image that is not selected is changed by its id. +#[test] +fn images_given_by_id_need_not_be_selected() { + let (mut s, id) = open(&framed()); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!( + s.execute(COLOR_MASK, &json!({"ids": [id.0], "background": "colors", "colors": "#fff", "tolerance": 0, "softness": 0})).unwrap()["changed"], + 1 + ); + assert_eq!(s.execute(REMOVE_COLOR_MASK, &json!({"ids": [id.0]})).unwrap()["changed"], 1); + s.execute(COLOR_MASK, &json!({"ids": [id.0], "background": "colors", "colors": "#fff", "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({"ids": [id.0]})).unwrap()["changed"], 1); + assert_eq!(mask_of(doc(&s), id), None); +} + +#[test] +fn what_cannot_be_masked_is_refused_and_nothing_changes() { + let (mut s, id) = open(&framed()); + let steps = undo_len(&s); + let e = s.execute(COLOR_MASK, &json!({"tolerance": 101})).unwrap_err(); + assert!(e.to_string().contains("`tolerance` must be from 0 to 100"), "{e}"); + let e = s + .execute(COLOR_MASK, &json!({"background": "colors", "colors": "#fff, #c80000", "contiguous": false, "tolerance": 0, "softness": 0})) + .unwrap_err(); + assert!(e.to_string().contains("the mask would hide every pixel"), "{e}"); + assert_eq!((mask_of(doc(&s), id), undo_len(&s)), (None, steps)); + let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [0, 0, 0, 0].repeat(8)).unwrap(); + let (mut s, _) = open(&ImageBlob::cmyk_tiff(&inks).unwrap().bytes); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err(); + assert!(e.to_string().contains("a CMYK image can't take a color mask"), "{e}"); + // Without settings, the dialog's query reads the settings and opens on the defaults. + assert_eq!(s.execute(COLOR_MASK, &json!({})).unwrap()["masked"], json!(false)); +} + +/// Remove Color Mask and Apply Color Mask refuse an object in `ids` that is not an image, as +/// Color Mask does. +#[test] +fn an_object_that_is_not_an_image_is_refused() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap(); + let steps = undo_len(&s); + for cmd in [COLOR_MASK, REMOVE_COLOR_MASK, APPLY_COLOR_MASK] { + let mut p = if cmd == COLOR_MASK { hide_white() } else { json!({}) }; + p["ids"] = json!([id.0, rect]); + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(&format!("object {rect} is not an image")), "{cmd}: {e}"); + } + assert!(mask_of(doc(&s), id).is_some()); + assert_eq!(undo_len(&s), steps); +} + +/// A mask that can't be made for the image's pixels (they are missing) is not made again on every +/// later edit: the art holding it is left as it is. +#[test] +fn a_mask_that_cannot_be_made_is_not_made_again_on_every_edit() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = "gone".into(); + } + super::refresh(None, &mut d); + assert_eq!(mask_of(&d, id).unwrap().key, "gone", "it hides nothing"); + let layer = d.layers[0].clone(); + super::refresh(None, &mut d); + assert!(std::sync::Arc::ptr_eq(&layer, &d.layers[0]), "the second pass changes nothing"); +} + +/// The original of a pattern being edited takes its masks again too. +#[test] +fn the_pattern_being_edited_takes_its_masks_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + let mut art = d.node(id).unwrap().clone(); + let red = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, |x, _| image::Rgba(if x == 0 { WHITE } else { RED }))))); + let key = red.content_key(); + d.images.insert(key.clone(), red); + if let NodeKind::Image(im) = &mut art.kind { + im.key = key.clone(); + } + let original = vectorcraft_doc::PatternDef::new("Tile", vec![std::sync::Arc::new(art)]); + d.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: NodeId(999_999), original: Some(original) }); + super::refresh(None, &mut d); + let original = d.pattern_edit.as_ref().and_then(|e| e.original.as_ref()).unwrap(); + assert_eq!(Document::color_mask_of(&original.art[0]).unwrap().from, key); +} + +/// A linked file read again as the document opens (Update Links: Automatically) takes the mask +/// for its new pixels. +#[test] +fn a_linked_file_read_again_on_open_takes_the_mask_again() { + let dir = crate::tests_links::Folder::new("color-mask"); + let pic = dir.file("art.png"); + crate::tests_links::write(&pic, &framed()); + let mut s = crate::tests_links::session(); + let id = crate::tests_links::place(&mut s, &pic); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let saved = dir.file("art.vectorcraft"); + crate::tests_links::save(&mut s, &saved); + // New pixels in the file: blue where the block was red. + let blue = png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(12, 10, |x, y| { + image::Rgba(if (3..9).contains(&x) && (3..7).contains(&y) { [0, 0, 200, 255] } else { WHITE }) + }))); + crate::tests_links::write(&pic, &blue); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let r = crate::tests_links::open(&mut s, &saved); + assert_eq!(r["updatedLinks"][0]["ids"], json!([id.0]), "{r}"); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!(m.from, im.key, "made for the file's new pixels"); + let px = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(4, 4).0), (0, [0, 0, 200, 255])); +} + +/// The bytes of every file `format` writes for `d` with `p`. +fn written(d: &Document, format: &str, p: Value) -> Vec> { + crate::cmd::fileio::encode_all(d, format, &p).unwrap().files.into_iter().map(|f| f.1).collect() +} + +/// `out` for the opened image without a mask, with one hiding its red block, and with the mask +/// removed again: the mask changes what is written, and removing it gives the same output as +/// before. (Outputs that flatten see-through pixels over the white page show the hidden block as +/// white.) +fn written_with_the_mask(out: impl Fn(&Document) -> T) { + let (mut s, _) = open(&framed()); + let plain = out(doc(&s)); + s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#c80000", "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let masked = out(doc(&s)); + s.execute(REMOVE_COLOR_MASK, &json!({})).unwrap(); + assert_ne!(plain, masked, "the masked pixels are written"); + assert_eq!(out(doc(&s)), plain, "without the mask, the image's own"); +} + +#[test] +fn dxf_writes_the_masked_pixels() { + written_with_the_mask(|d| written(d, "dxf", json!({}))); +} + +#[test] +fn emf_and_wmf_write_the_masked_pixels() { + written_with_the_mask(|d| written(d, "emf", json!({}))); + written_with_the_mask(|d| written(d, "wmf", json!({}))); +} + +#[test] +fn legacy_ai_writes_the_masked_pixels() { + written_with_the_mask(|d| written(d, "ai", json!({"aiVersion": 7}))); +} + +#[test] +fn ai_editing_data_writes_the_masked_pixels() { + let o = vectorcraft_eps::AiEditingOptions { title: "art".into(), created: Some(0) }; + written_with_the_mask(|d| vectorcraft_eps::export_ai_editing(d, &o).unwrap().program); +} + +/// PDF/X-1a flattens a masked image, whose hidden pixels are transparency, and writes no soft +/// mask. +#[test] +fn pdf_x1a_flattens_the_hidden_pixels() { + let (mut s, _) = open(&framed()); + let export = |s: &mut Session| { + let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a", "compression": {"compressText": false}})).unwrap(); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8_lossy(&bytes).into_owned(), v["warnings"].to_string()) + }; + let (_, warnings) = export(&mut s); + assert!(!warnings.contains("flattened"), "an opaque image: {warnings}"); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let (text, warnings) = export(&mut s); + assert!(warnings.contains("flattened"), "{warnings}"); + assert!(!text.contains("/SMask")); +} + +/// SVG writes a colorized gray image with a mask as the coverage of its masked pixels, shared by +/// the objects that show it. +#[test] +fn svg_writes_a_colorized_masked_image_as_it_shows() { + let (mut s, _) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#000000", "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + s.execute("edit.duplicate", &json!({})).unwrap(); + let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let text = r["text"].as_str().unwrap(); + assert_eq!(text.matches("data:image/png;base64,").count(), 1, "one shared image: {text}"); + assert_eq!(svg_image(text).to_rgba8().get_pixel(4, 4).0[3], 0, "the hidden black block covers nothing"); +} + +/// The opened masked image moved into what `wrap` makes of it (given the document, to take ids +/// from, and the image node). +fn wrapped(wrap: impl FnOnce(&mut Document, vectorcraft_doc::Node) -> vectorcraft_doc::Node) -> (Session, NodeId) { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.edit("Wrap", |d, _| { + let (parent, index, _) = d.position(id).unwrap(); + let image = (*d.remove(id)?).clone(); + let holder = wrap(d, image); + d.insert(parent, index, holder)?; + Ok(()) + }) + .unwrap(); + (s, id) +} + +/// The rectangle of the opened image's frame (12 × 10 at the origin) filled with `fill`. +fn frame(d: &mut Document, fill: vectorcraft_color::Paint) -> vectorcraft_doc::Node { + let rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 10.0)); + vectorcraft_doc::Node::path(d.alloc_id(), rect, vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0)) +} + +/// An image in a clip group of its own frame, which the canvas draws straight into the clipping +/// path, shows its masked pixels. +#[test] +fn a_clipped_image_draws_its_masked_pixels() { + let (s, id) = wrapped(|d, image| { + let clip = frame(d, vectorcraft_color::Paint::None); + vectorcraft_doc::Node::new( + d.alloc_id(), + NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)], clip: true }, + ) + }); + assert_eq!((drawn(doc(&s), id, 0, 0)[3], drawn(doc(&s), id, 4, 4)), (0, RED)); +} + +/// An image whose opacity mask is one white shape, which the canvas draws straight into the +/// shape, shows its masked pixels. +#[test] +fn an_image_masked_by_a_white_shape_draws_its_masked_pixels() { + let (s, id) = wrapped(|d, image| { + let white = frame(d, vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE)); + image.with_mask(Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true)))) + }); + assert_eq!((drawn(doc(&s), id, 0, 0)[3], drawn(doc(&s), id, 4, 4)), (0, RED)); +} + +/// Pasting into a document that has other pixels under the image's names renames them, and the +/// mask follows: the masked pixels come along under their new name, nothing is decoded again. +#[test] +fn paste_renames_the_masked_pixels_with_the_image() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let m = mask_of(doc(&s), id).unwrap(); + let masked = doc(&s).images[&m.key].bytes.clone(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let other = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba(RED))))); + s.edit("Taken names", |d, _| { + d.images.insert(m.key.clone(), other.clone()); + d.images.insert(m.from.clone(), other.clone()); + Ok(()) + }) + .unwrap(); + super::super::DECODES.with(|c| c.set(0)); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(super::super::DECODES.with(|c| c.get()), 0, "nothing is decoded to make the mask again"); + let id = s.doc().unwrap().selection.objects[0]; + let d = doc(&s); + let (im, pasted) = (image_of(d, id), mask_of(d, id).unwrap()); + assert!(pasted.key != m.key && pasted.from != m.from, "renamed: {pasted:?}"); + assert!(pasted.made_for(&im)); + assert_eq!(d.images[&pasted.key].bytes, masked); + assert_eq!((drawn(d, id, 0, 0)[3], drawn(d, id, 4, 4)), (0, RED)); +} + +/// Placing a document (as an editable copy) whose image has a mask brings the masked pixels, and +/// the mask follows names the placing document already uses for other pixels. +#[test] +fn placing_a_document_brings_the_masked_pixels() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let m = mask_of(doc(&s), id).unwrap(); + let file = vectorcraft_format::save_file(doc(&s)); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let other = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba(RED))))); + s.edit("Taken names", |d, _| { + d.images.insert(m.from.clone(), other.clone()); + Ok(()) + }) + .unwrap(); + super::super::DECODES.with(|c| c.set(0)); + let r = + s.execute("file.place", &json!({"name": "art.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file), "link": false})).unwrap(); + assert_eq!(super::super::DECODES.with(|c| c.get()), 0, "nothing is decoded to make the mask again: {r}"); + let d = doc(&s); + let mut found = vec![]; + for root in d.art_roots() { + root.walk(&mut |n| { + if let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) { + found.push(((**im).clone(), m.clone())); + } + }); + } + let [(im, placed)] = found.as_slice() else { panic!("one masked image: {found:?}") }; + assert!(placed.made_for(im) && placed.from != m.from, "the image was renamed and the mask follows: {placed:?}"); + let px = image::load_from_memory(&d.images[&placed.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(4, 4).0), (0, RED)); +} diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs index df27180e82..4299b2a25a 100644 --- a/crates/engine/src/cmd/addons/image/tests.rs +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -343,7 +343,7 @@ fn the_color_profile_is_kept() { encoder.set_icc_profile(icc.clone()).unwrap(); DynamicImage::ImageRgba8(framed()).write_with_encoder(encoder).unwrap(); let blob = ImageBlob::png(bytes); - let out = super::clear(&blob, &settings(Background::Edges), None).unwrap().unwrap(); + let out = super::clear(&blob, &settings(Background::Edges), None, super::C).unwrap().unwrap(); let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(out.blob.bytes.as_slice())).unwrap(); assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); } diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index b1377d2fef..7ba8d46781 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -119,6 +119,10 @@ fn node_refs(n: &Node, f: Visit) { if let Some(mask) = &m.extras().mask { node_refs(&mask.art, f); } + // A color-masked image's masked pixels go with it. + if let Some(cm) = Document::color_mask_of(m) { + f(Ref::Name(Res::Image, &cm.key)); + } match &m.kind { NodeKind::Text(t) => { for r in &t.runs { @@ -648,6 +652,10 @@ impl Relink { if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.node(Arc::make_mut(&mut m.art)); } + if let Some(cm) = n.extras_if_any().and_then(|e| e.color_mask.as_deref_mut()) { + self.name(Res::Image, &mut cm.key); + self.name(Res::Image, &mut cm.from); + } match &mut n.kind { NodeKind::Text(t) => { for r in &mut t.runs { diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index cf7bdb7baa..4ad89c773b 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -76,12 +76,13 @@ pub(crate) fn see_through_images(doc: &Document) -> BTreeSet { doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect() } -/// `n` shows an image of `keys` (itself or inside). +/// `n` shows an image of `keys` (itself or inside), a color-masked image's masked pixels counted as +/// what it shows. pub(super) fn draws_image(n: &Node, keys: &BTreeSet) -> bool { !keys.is_empty() && n.visible && match &n.kind { - NodeKind::Image(im) => keys.contains(&im.key), + NodeKind::Image(im) => keys.contains(&im.key) || Document::color_mask_of(n).is_some_and(|m| m.made_for(im) && keys.contains(&m.key)), _ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))), } } diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index a5881c76ef..16cf8e5de7 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -155,7 +155,8 @@ impl FlattenOptions { /// the paint ([`vectorcraft_doc::ImageBlob::takes_colorize`]) never counts: it draws uncolorized. pub(crate) fn counts_colorize(&self, d: &Document, n: &Node) -> bool { let NodeKind::Image(im) = &n.kind else { return true }; - let blob = d.image_blob(im); + let shown = d.shown_image(n, im); + let blob = d.image_blob(&shown); if blob.is_some_and(|b| !b.takes_colorize()) { return false; } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..e8413282b3 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1058,6 +1058,8 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps }; Nested { pick: &pick, make: &mut make, next: 0 }.run(d); } + // Color masks of images whose files were read again are made for the new pixels. + super::refresh_color_masks(None, d); out } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 1dd1548363..d8d35e4423 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -1043,6 +1043,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { bare.blend = Default::default(); bare.appearance = Appearance::default().into(); bare.visible = true; + // The image's own pixels are cut, not the masked ones a color mask shows: the mask is made + // again for the cut pixels after the edit. + if let Some(e) = bare.extras_if_any() { + e.color_mask = None; + } let tmp = isolated_doc(&st.doc, vec![bare]); let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale); let (png, w, h, warning) = diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b199f9a356..31cb5c72e6 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -701,7 +701,9 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: out.push(*id); continue; } - let Some(gray) = d.image_blob(im).and_then(|b| b.gray_source()) else { continue }; + // A color-masked image is colorized as it shows: its masked pixels, which have an alpha + // channel. + let Some(gray) = d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) else { continue }; match paint { // Without a color, `none` goes to the image's fill item. Paint::None => continue, @@ -740,7 +742,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) && !refused && !colorizes_transparency(d, paint) - && d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) + && d.image_blob(&d.shown_image(m, im)).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) { refused = true; } diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index fb0622632e..15a39f06e5 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -78,6 +78,12 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) { NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)), _ => {} } + // A color-masked image's masked pixels come along, and the key they were made from + // follows the image's. + if let Some(cm) = n.extras_if_any().and_then(|e| e.color_mask.as_deref_mut()) { + f(Ref::Image(&mut cm.key)); + f(Ref::Image(&mut cm.from)); + } }); } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4c9abdb357..47e8e0db50 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -33,7 +33,10 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.swatchLegend", "addon.contour.set", "addon.cutting.bridges", + "addon.image.applyColorMask", + "addon.image.colorMask", "addon.image.removeBackground", + "addon.image.removeColorMask", "addon.pathtools.addPoints", "addon.pathtools.close", "addon.pathtools.corner", diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index c7d22ab6c7..0f3fc8e70b 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -31,6 +31,8 @@ impl Session { } // Pixel-aligned objects the edit changed go back on the pixel grid. cmd::refresh_pixel_aligned(&before, doc); + // Color masks of images whose own pixels the edit replaced are made for the new ones. + cmd::refresh_color_masks(Some(&*before), doc); let placed = cmd::objects_on_path::refresh(&before, doc); // Pixel-aligned members that placing moved go back on the pixel grid. cmd::keep_aligned_in(doc, &placed); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..3455aa2cca 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -635,7 +635,7 @@ impl<'a> Writer<'a> { } NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), - NodeKind::Image(im) => self.image(im, xy), + NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy), NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { if !self.open_group("u") { diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 5e058acaf6..f7ed36f4ff 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -172,7 +172,7 @@ pub fn writes_image_mask(doc: &Document, n: &Node) -> bool { matches!(n.extras().colorize.as_deref(), Some(Paint::Solid { .. })) && !n.extras().mask.as_ref().is_some_and(|m| !m.disabled) && doc - .image_blob(im) + .image_blob(&doc.shown_image(n, im)) .is_some_and(|b| b.takes_colorize() && fits(&b.bytes) && b.coverage().is_some_and(|(_, _, c)| c.iter().all(|&v| v == 0 || v == 255))) } diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 3373330892..d133208b06 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -265,7 +265,8 @@ impl<'a> Writer<'a> { } NodeKind::Image(im) => { self.lock(locked); - let blob = self.doc.image_blob(im); + let shown = self.doc.shown_image(n, im); + let blob = self.doc.image_blob(&shown); // A colorize color the renderer draws the image in. let colorize = n.extras().colorize.as_deref().filter(|p| matches!(p, Paint::Solid { .. }) && blob.is_some_and(ImageBlob::takes_colorize)); diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 96205aa7d3..66f182b1e9 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -363,6 +363,8 @@ impl<'a> Scene<'a> { } NodeKind::Text(t) => self.text(n, t), NodeKind::Image(im) => { + let shown = self.doc.shown_image(n, im); + let im = &*shown; let blob = self.doc.image_blob(im); match n.extras().colorize.as_deref() { Some(p @ Paint::Solid { .. }) if blob.is_none_or(|b| b.gray_source().is_some()) => { diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 343b422093..c601c71840 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -213,7 +213,7 @@ impl<'a> Scene<'a> { self.node(&o, true); } } - NodeKind::Image(im) => self.image(im), + NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im)), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 2143ecba61..c377b9f7e4 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -165,6 +165,9 @@ impl Exporter<'_> { pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask, interpolate: bool) -> Option { let art = &m.art; let NodeKind::Image(mi) = &art.kind else { return None }; + // A color-masked image in the mask's art masks with the pixels it shows. + let shown = self.doc.shown_image(art, mi); + let mi = &*shown; let plain = art.opacity >= 1.0 && art.blend == BlendMode::Normal && art.extras().mask.is_none() && !art.appearance.effects.iter().any(|e| e.visible); if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 90b7ae5248..4c97f99812 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -249,8 +249,9 @@ impl Exporter<'_> { self.shape(s, n, &bp, *rule, page); } NodeKind::Text(t) => self.text(s, n, t, page), - NodeKind::Image(im) => match n.extras().colorize.as_deref() { - Some(p @ Paint::Solid { .. }) if self.doc.image_blob(im).is_none_or(vectorcraft_doc::ImageBlob::takes_colorize) => { + NodeKind::Image(im) => match (n.extras().colorize.as_deref(), self.doc.shown_image(n, im)) { + (Some(p @ Paint::Solid { .. }), im) if self.doc.image_blob(&im).is_none_or(vectorcraft_doc::ImageBlob::takes_colorize) => { + let im = &*im; if writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { if let Some(colorized) = self.colorized_image(im, p, !n.extras().pixelated) { self.image(s, im, Some(colorized), n.extras().pixelated); @@ -263,7 +264,7 @@ impl Exporter<'_> { self.warn(super::exporter::UNWRITABLE_IMAGE); } } - _ => self.image(s, im, alpha, n.extras().pixelated), + (_, im) => self.image(s, &im, alpha, n.extras().pixelated), }, NodeKind::SymbolInstance { symbol, xf } => { // Within the objects a frame may draw of symbol instances, as on screen: nested diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index a46d1137a7..c1e239f57c 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -167,9 +167,14 @@ fn keep_colorized_pixels(n: &mut Arc, images: &BTreeMap { node.extras_mut().colorize = None; } + // A colorized image with a color mask is separated as it shows: its masked pixels. + let mask = if colorized(node) { node.extras_if_any().and_then(|e| e.color_mask.take()) } else { None }; if colorized(node) && let NodeKind::Image(im) = &mut node.kind { + if let Some(m) = mask.filter(|m| m.made_for(im) && images.contains_key(&m.key)) { + im.key = m.key; + } keys.insert(im.key.clone()); im.key = colorized_key(&im.key); } diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 6abe2f3885..2ad5ab8bba 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -380,6 +380,11 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_ /// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid /// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none). fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec>>) { + export_smasks(&masked_doc(mask, clip, invert)) +} + +/// A document of a 2 × 2 CMYK image masked by PNG `mask` (key `alpha`) on its own pixel grid. +fn masked_doc(mask: Vec, clip: bool, invert: bool) -> Document { let ink = [0u8, 102, 255, 0]; let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, flat(ink, 4)).unwrap()).unwrap()); @@ -400,8 +405,14 @@ fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec (Vec, Vec>>) { let press = builtin_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings; - let out = export_with_report(&d, &PdfOptions { settings: press, created: Some(1_700_000_000), ..Default::default() }).unwrap(); + let out = export_with_report(d, &PdfOptions { settings: press, created: Some(1_700_000_000), ..Default::default() }).unwrap(); let file = Pdf::new(Arc::new(out.bytes.clone())).unwrap(); let smasks = file .objects() @@ -440,6 +451,34 @@ fn a_cmyk_image_masked_by_a_grey_image_of_its_grid_writes_it_as_its_smask() { assert!(String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "a luminosity soft mask"); } +/// A grey mask image with a color mask masks the CMYK image with the pixels it shows: its hidden +/// pixels are clear in the image's `/SMask`. +#[test] +fn a_color_masked_grid_mask_image_writes_the_pixels_it_shows_as_the_smask() { + let mut d = masked_doc(png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()), true, false); + // The last pixel hidden. + d.images.insert( + "shown".into(), + ImageBlob::new("image/png", png(image::GrayAlphaImage::from_raw(2, 2, vec![255, 255, 128, 255, 0, 255, 64, 0]).unwrap())), + ); + let id = d.layers[0].children().unwrap()[0].id; + let mut mask = d.node(id).unwrap().extras().mask.clone().unwrap(); + let art = std::sync::Arc::make_mut(&mut mask.art); + art.extras_mut().color_mask = Some(Box::new(vectorcraft_doc::color_mask::ColorMask { + key: "shown".into(), + from: "alpha".into(), + background: vectorcraft_doc::color_mask::MaskBackground::Colors, + colors: vec![[64, 64, 64]], + contiguous: false, + measure: vectorcraft_doc::color_mask::MaskMatch::Distance, + tolerance: 0.0, + softness: 0.0, + })); + d.node_mut(id).unwrap().extras_mut().mask = Some(mask); + let (_, smasks) = export_smasks(&d); + assert_eq!(smasks, [Some(vec![255, 128, 0, 0])]); +} + /// A pixelated CMYK image (an envelope's with Anti-Alias off) masked by a grey image of its grid /// is written without /Interpolate, as one without a mask is. #[test] diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index f81fc17514..4f2033609b 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -168,6 +168,40 @@ fn separations_print_the_image_on_its_ink_plate() { } } +/// Printed separations separate a colorized image with a color mask as it shows: the pixels the +/// mask hides print on no plate. +#[test] +fn separations_print_a_color_masked_image_as_it_shows() { + let spot = Paint::Solid { color: Color::cmyk(0.0, 1.0, 1.0, 0.0), swatch: Some("Ink".into()), tint: 1.0 }; + let mut d = doc(ColorMode::Cmyk, Some(spot)); + // The black pixel hidden. + let mut png = vec![]; + image::GrayAlphaImage::from_raw(2, 1, vec![0, 0, 255, 255]) + .unwrap() + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + d.images.insert("shown".into(), ImageBlob::png(png)); + let id = d.layers[0].children().unwrap()[0].id; + d.node_mut(id).unwrap().extras_mut().color_mask = Some(Box::new(vectorcraft_doc::color_mask::ColorMask { + key: "shown".into(), + from: "pic".into(), + background: vectorcraft_doc::color_mask::MaskBackground::Colors, + colors: vec![[0, 0, 0]], + contiguous: false, + measure: vectorcraft_doc::color_mask::MaskMatch::Distance, + tolerance: 0.0, + softness: 0.0, + })); + let settings: PrintSettings = serde_json::from_value(serde_json::json!({"output": {"mode": "separations"}})).unwrap(); + let job = plan(&d, &PrintOptions { settings, ..Default::default() }).unwrap(); + assert!(!job.plates.is_empty()); + for p in &job.plates { + let n = &p.layers[0].children().unwrap()[0]; + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + assert_eq!(p.image_blob(im).and_then(|b| b.coverage()).unwrap().2, [0, 0], "the hidden black pixel prints on no plate"); + } +} + /// A linked image showing the missing-link placeholder is written in the placeholder's own colors /// though it keeps a colorize paint for its file, and printed separations separate it as an /// uncolorized image. diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs index a604481dd4..76b3bf53f2 100644 --- a/crates/render/src/renderer/images.rs +++ b/crates/render/src/renderer/images.rs @@ -11,7 +11,7 @@ use crate::images; pub(super) fn image_tint(doc: &Document, n: &Node, ink: Ink) -> Option<[u8; 3]> { let c = n.extras().colorize.as_deref()?.color()?; if let NodeKind::Image(im) = &n.kind - && doc.image_blob(im).is_some_and(|b| !b.takes_colorize()) + && doc.image_blob(&doc.shown_image(n, im)).is_some_and(|b| !b.takes_colorize()) { return None; } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index ca6611ea5f..47f2dec770 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -250,7 +250,7 @@ impl Renderer { && let Some(region) = self.clip_of(&m.art) && shape_masked_image(a, im, ®ion.0) { - self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(f.doc, a, f.ink), a.extras().pixelated); + self.draw_image_in(ctx, f, &f.doc.shown_image(a, im), Some(®ion), images::image_tint(f.doc, a, f.ink), a.extras().pixelated); self.stats.drawn += 1; return; } @@ -518,7 +518,8 @@ impl Renderer { && let NodeKind::Image(im) = &image.kind && plain_image(image, im, ®ion.0) { - self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(f.doc, image, f.ink), image.extras().pixelated); + let im = f.doc.shown_image(image, im); + self.draw_image_in(ctx, f, &im, Some(®ion), images::image_tint(f.doc, image, f.ink), image.extras().pixelated); self.stats.drawn += 1; return; } @@ -568,7 +569,9 @@ impl Renderer { } } NodeKind::Text(t) => self.draw_text(ctx, f, n, t), - NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(f.doc, n, f.ink), n.extras().pixelated), + NodeKind::Image(im) => { + self.draw_image_in(ctx, f, &f.doc.shown_image(n, im), None, images::image_tint(f.doc, n, f.ink), n.extras().pixelated) + } NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { // The instance draws when the frame has room for its art's objects; a symbol diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 3ab4003d7d..9a035e9eb9 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -748,6 +748,8 @@ impl Writer<'_> { } NodeKind::Text(t) => self.text_node(n, t), NodeKind::Image(im) => { + let shown = self.doc.shown_image(n, im); + let im = &*shown; if let Some(Paint::Solid { color, .. }) = n.extras().colorize.as_deref() && let Some(blob) = self.doc.image_blob(im) && blob.gray_source().is_some() diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index e982ab315c..cce0ac41a0 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -42,6 +42,8 @@ impl Writer<'_> { art.walk(&mut |n| { let NodeKind::Image(im) = &n.kind else { return }; let Some(Paint::Solid { color, .. }) = n.extras().colorize.as_deref() else { return }; + let shown = self.doc.shown_image(n, im); + let im = &*shown; if self.doc.image_blob(im).is_none_or(|blob| blob.gray_source().is_none()) { return; } @@ -176,12 +178,14 @@ impl Writer<'_> { let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); art.walk(&mut |c| { if let NodeKind::Image(im) = &c.kind { + let shown = self.doc.shown_image(c, im); + let im = &*shown; if matches!(c.extras().colorize.as_deref(), Some(Paint::Solid { .. })) && self.doc.image_blob(im).is_none_or(|b| b.gray_source().is_some()) { return; } - seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; + seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, im.clone())).0 += 1; } }); let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); diff --git a/crates/ui-egui/src/addons/image.rs b/crates/ui-egui/src/addons/image.rs index f8aa72b8a1..9388b104e2 100644 --- a/crates/ui-egui/src/addons/image.rs +++ b/crates/ui-egui/src/addons/image.rs @@ -1,6 +1,7 @@ //! Image Tools in the interface: its submenu (Object › Plug-ins › Image Tools). Remove Background's //! menu item opens the generic command dialog (`ui.paramDialog`) on its options, with a Preview -//! (`dialogs::command`). +//! (`dialogs::command`). Color Mask's opens the same dialog on the selected image's mask settings +//! (the command's report, see `menus::invoke`), or the defaults for an image without one. use serde_json::json; @@ -8,18 +9,29 @@ use crate::menus::Item; /// Remove Background's command. pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground"; -/// Remove Background › Background: what the command's `background` takes, by name. +/// Color Mask's command. +pub const COLOR_MASK: &str = "addon.image.colorMask"; +/// Remove Color Mask's command. +pub const REMOVE_COLOR_MASK: &str = "addon.image.removeColorMask"; +/// Apply Color Mask's command. +pub const APPLY_COLOR_MASK: &str = "addon.image.applyColorMask"; +/// Remove Background and Color Mask › Background: what the commands' `background` takes, by name. pub const BACKGROUNDS: &[(&str, &str)] = &[("Edge Color", "edges"), ("Corner Color", "corners"), ("Picked Colors", "colors")]; -/// Remove Background › Match: what the command's `match` takes, by name. +/// Remove Background and Color Mask › Match: what the commands' `match` takes, by name. pub const MATCHES: &[(&str, &str)] = &[("Distance", "distance"), ("Each Channel", "channels")]; /// Object › Plug-ins › Image Tools. pub fn menu() -> Vec { - vec![Item::Cmd( - "Remove Background…", - REMOVE_BACKGROUND, - json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10, "softness": 10}), - )] + vec![ + Item::Cmd( + "Remove Background…", + REMOVE_BACKGROUND, + json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10, "softness": 10}), + ), + Item::Cmd("Color Mask…", COLOR_MASK, json!({})), + Item::Cmd("Remove Color Mask", REMOVE_COLOR_MASK, json!({})), + Item::Cmd("Apply Color Mask", APPLY_COLOR_MASK, json!({})), + ] } #[cfg(test)] diff --git a/crates/ui-egui/src/addons/image/tests.rs b/crates/ui-egui/src/addons/image/tests.rs index 64563e611b..67b5b48ec5 100644 --- a/crates/ui-egui/src/addons/image/tests.rs +++ b/crates/ui-egui/src/addons/image/tests.rs @@ -41,13 +41,75 @@ fn alphas(app: &VectorcraftApp) -> [u8; 3] { } fn open(app: &mut VectorcraftApp) { - let entries = menus::menu_entries(app); - let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Image Tools"] && e.label == "Remove Background…").unwrap(); - assert!(item.enabled); + click(app, "Remove Background…"); +} + +/// Choose Object › Plug-ins › Image Tools › `label`, which must be enabled. +fn click(app: &mut VectorcraftApp, label: &str) { + let item = menu_item(app, label); + assert!(item.enabled, "{label} enabled"); let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); menus::invoke(app, &id, p); } +fn menu_item(app: &VectorcraftApp, label: &str) -> menus::MenuEntry { + menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Plug-ins", "Image Tools"] && e.label == label).unwrap() +} + +/// The selected image's color mask, if it has one. +fn mask(app: &VectorcraftApp) -> Option { + let st = app.session.doc().unwrap(); + st.selection.objects.first().and_then(|id| st.doc.node(*id)).and_then(vectorcraft_doc::Document::color_mask_of).cloned() +} + +/// The alpha of the selected image's masked pixels at the corner, on the ring and in the block +/// (all 255 without a mask). +fn masked_alphas(app: &VectorcraftApp) -> [u8; 3] { + let Some(m) = mask(app) else { return [255; 3] }; + let px = image::load_from_memory(&app.session.doc().unwrap().doc.images[&m.key].bytes).unwrap().to_rgba8(); + [(0, 0), (1, 1), (4, 4)].map(|(x, y)| px.get_pixel(x, y).0[3]) +} + +/// Object › Plug-ins › Image Tools › Color Mask… opens the command dialog on the defaults, previews +/// a mask while the image's pixels stay, and keeps it on OK as one step; opened again it shows the +/// image's mask settings. Remove Color Mask and Apply Color Mask are enabled only for an image +/// with a mask. +#[test] +fn color_mask_previews_keeps_its_settings_and_comes_off() { + let mut app = app(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let (start, n) = (image(&app), steps(&app)); + assert!(!menu_item(&app, "Remove Color Mask").enabled && !menu_item(&app, "Apply Color Mask").enabled); + + click(&mut app, "Color Mask…"); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::COLOR_MASK.to_string(), "Color Mask".to_string())); + assert_eq!((d.str("background"), d.f64("tolerance", -1.0), d.bool("preview")), ("edges".to_string(), 10.0, false)); + assert!(!d.fields.contains_key("masked")); + let shown = show(&mut app); + for s in ["Color Mask", "Edge Color", "Contiguous", "Match", "Tolerance", "Softness", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + set(&mut app, "preview", json!(true)); + set(&mut app, "tolerance", json!(20)); + show(&mut app); + assert_eq!(masked_alphas(&app), [0, 0, 255], "the preview hides the white and the ring"); + assert_eq!(image(&app), start, "the image's pixels stay"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((steps(&app), image(&app)), (n + 1, start.clone()), "one undo step; the pixels as they were"); + assert_eq!(mask(&app).map(|m| m.tolerance), Some(20.0)); + + // Opened again: the image's mask settings. + click(&mut app, "Color Mask…"); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("tolerance", -1.0), 20.0); + crate::dialogs::cancel(&mut app); + assert_eq!(steps(&app), n + 1); + + assert!(menu_item(&app, "Apply Color Mask").enabled); + click(&mut app, "Remove Color Mask"); + assert_eq!((mask(&app), image(&app), steps(&app)), (None, start, n + 2)); +} + fn show(app: &mut VectorcraftApp) -> String { painted_text(app, |app, ui| crate::dialogs::show(app, ui.ctx())) } diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 7c9aaf6873..67a1074b85 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -64,12 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path -/// works the same way (#5532), and so does Image Tools › Remove Background. (Cancel rolls a -/// preview back whichever dialog started it.) +/// works the same way (#5532), and so do Image Tools › Remove Background and Color Mask. (Cancel +/// rolls a preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), - "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true, + "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK => true, _ => false, } } @@ -119,7 +119,9 @@ fn order(command: &str) -> &'static [&'static str] { "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], crate::addons::typetools::RELEASE => &["mode", "keepOriginal"], - crate::addons::image::REMOVE_BACKGROUND => &["background", "colors", "contiguous", "match", "tolerance", "softness"], + crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK => { + &["background", "colors", "contiguous", "match", "tolerance", "softness"] + } crate::addons::cutting::BRIDGES => &["width", "count", "direction", "keepOriginal"], _ => &[], } @@ -141,8 +143,8 @@ fn choices(command: &str, key: &str) -> Option { ("graph.setType", "pieLegend") => Some(PIE_LEGEND), ("graph.setType", "piePosition") => Some(PIE_POSITION), ("graph.setType", "pieSort") => Some(PIE_SORT), - (crate::addons::image::REMOVE_BACKGROUND, "background") => Some(crate::addons::image::BACKGROUNDS), - (crate::addons::image::REMOVE_BACKGROUND, "match") => Some(crate::addons::image::MATCHES), + (crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK, "background") => Some(crate::addons::image::BACKGROUNDS), + (crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK, "match") => Some(crate::addons::image::MATCHES), _ => None, } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e666fcc8c6..a82ccaa3e6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -696,6 +696,10 @@ font-technology Variable Променливи Corner Color Цвят на ъглите Contiguous Съседни Each Channel Всеки канал + Color Mask… Цветова маска… + Color Mask Цветова маска + Remove Color Mask Премахване на цветовата маска + Apply Color Mask Прилагане на цветовата маска Edit Path Редактиране на контура Mask Маска Crop Marks Знаци за изрязване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2381deb135..14cd541b13 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -206,6 +206,10 @@ Corner Color Boja uglova Contiguous Susjedni Each Channel Svaki kanal + Color Mask… Maska boja… + Color Mask Maska boja + Remove Color Mask Ukloni masku boja + Apply Color Mask Primijeni masku boja Edit Path Uredi putanju Mask Maska Rasterize… Rasteriziraj… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..d12c9e81b6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1006,6 +1006,10 @@ antiAlias Sharp Nítid Corner Color Color de les cantonades Contiguous Contigu Each Channel Cada canal + Color Mask… Màscara de color… + Color Mask Màscara de color + Remove Color Mask Elimina la màscara de color + Apply Color Mask Aplica la màscara de color Crop Marks Marques de retall Crop To Retalla fins a Cross Creu @@ -5827,6 +5831,10 @@ digits Malayalam Malaiàlam @msg a CMYK image has no transparency to set una imatge CMYK no té transparència per establir @msg every pixel matches the background: no image would be left cada píxel coincideix amb el fons: no quedaria cap imatge @msg an image of {w} × {h} pixels is too large to change una imatge de {w} × {h} píxels és massa gran per canviar-la +@msg select an image with a color mask selecciona una imatge amb una màscara de color +@msg the color mask's settings are out of range la configuració de la màscara de color és fora de l’interval +@msg a CMYK image can't take a color mask una imatge CMYK no pot tenir una màscara de color +@msg the mask would hide every pixel la màscara amagaria tots els píxels @msg select an Image Trace object selecciona un objecte d’Image Trace @msg select an Intertwine group selecciona un grup Intertwine @msg select an object in perspective selecciona un objecte en perspectiva diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..5c6c8feb19 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -212,6 +212,10 @@ Corner Color Barva rohů Contiguous Souvislé Each Channel Každý kanál + Color Mask… Barevná maska… + Color Mask Barevná maska + Remove Color Mask Odstranit barevnou masku + Apply Color Mask Použít barevnou masku Edit Path Upravit cestu Mask Maska Rasterize… Rastrovat… @@ -4978,6 +4982,10 @@ imageTrace Outline Obrys @msg a CMYK image has no transparency to set obraz CMYK nemá průhlednost, kterou by bylo možné nastavit @msg every pixel matches the background: no image would be left každý obrazový bod odpovídá pozadí: nezbyl by žádný obraz @msg an image of {w} × {h} pixels is too large to change obraz o velikosti {w} × {h} obrazových bodů je příliš velký na změnu +@msg select an image with a color mask vyberte obraz s barevnou maskou +@msg the color mask's settings are out of range nastavení barevné masky jsou mimo rozsah +@msg a CMYK image can't take a color mask obraz CMYK nemůže mít barevnou masku +@msg the mask would hide every pixel maska by skryla všechny obrazové body @msg select an object in perspective vyberte objekt v perspektivě @msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení @msg Recolor completed; {locked} and {hidden} were left unchanged Přebarvení dokončeno; beze změny: {locked} a {hidden} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f48e9d6ea3..29e91d0ed8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -199,6 +199,10 @@ Corner Color Hjørnefarve Contiguous Sammenhængende Each Channel Hver kanal + Color Mask… Farvemaske… + Color Mask Farvemaske + Remove Color Mask Fjern farvemaske + Apply Color Mask Anvend farvemaske Edit Path Redigér kurve Mask Maskér Rasterize… Rasterisér… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..2b156aab58 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -689,6 +689,10 @@ font-technology Variable Variabel Corner Color Eckfarbe Contiguous Zusammenhängend Each Channel Jeder Kanal + Color Mask… Farbmaske… + Color Mask Farbmaske + Remove Color Mask Farbmaske entfernen + Apply Color Mask Farbmaske anwenden Edit Path Pfad bearbeiten Mask Maske Crop Marks Schnittmarken @@ -4595,6 +4599,10 @@ theme Appearance Erscheinungsbild @msg a CMYK image has no transparency to set ein CMYK-Bild hat keine Transparenz, die festgelegt werden kann @msg every pixel matches the background: no image would be left jedes Pixel entspricht dem Hintergrund: Es würde kein Bild übrig bleiben @msg an image of {w} × {h} pixels is too large to change ein Bild mit {w} × {h} Pixeln ist zu groß, um geändert zu werden +@msg select an image with a color mask wählen Sie ein Bild mit einer Farbmaske aus +@msg the color mask's settings are out of range die Einstellungen der Farbmaske liegen außerhalb des zulässigen Bereichs +@msg a CMYK image can't take a color mask ein CMYK-Bild kann keine Farbmaske erhalten +@msg the mask would hide every pixel die Maske würde jedes Pixel ausblenden @msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus @msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus @msg Recolor completed; {locked} and {hidden} were left unchanged Neufärben abgeschlossen; unverändert: {locked} und {hidden} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..28f7a3775e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -670,6 +670,10 @@ font-technology Variable Μεταβλητές Corner Color Χρώμα γωνιών Contiguous Συνεχόμενα Each Channel Κάθε κανάλι + Color Mask… Μάσκα χρώματος… + Color Mask Μάσκα χρώματος + Remove Color Mask Αφαίρεση μάσκας χρώματος + Apply Color Mask Εφαρμογή μάσκας χρώματος Edit Path Επεξεργασία διαδρομής Mask Μάσκα Crop Marks Σημάδια ξακρίσματος @@ -4619,6 +4623,10 @@ digits Malayalam Μαλαγιάλαμ @msg a CMYK image has no transparency to set μια εικόνα CMYK δεν έχει διαφάνεια για να οριστεί @msg every pixel matches the background: no image would be left κάθε εικονοστοιχείο ταιριάζει με το φόντο: δεν θα έμενε καμία εικόνα @msg an image of {w} × {h} pixels is too large to change η εικόνα {w} × {h} εικονοστοιχείων είναι υπερβολικά μεγάλη για να αλλάξει +@msg select an image with a color mask επιλέξτε εικόνα με μάσκα χρώματος +@msg the color mask's settings are out of range οι ρυθμίσεις της μάσκας χρώματος είναι εκτός εύρους +@msg a CMYK image can't take a color mask μια εικόνα CMYK δεν μπορεί να έχει μάσκα χρώματος +@msg the mask would hide every pixel η μάσκα θα έκρυβε κάθε εικονοστοιχείο @msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική @msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό @msg Recolor completed; {locked} and {hidden} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {locked} και {hidden} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..40fb7474b3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -710,6 +710,10 @@ font-technology Variable Variables Corner Color Color de las esquinas Contiguous Contiguo Each Channel Cada canal + Color Mask… Máscara de color… + Color Mask Máscara de color + Remove Color Mask Quitar máscara de color + Apply Color Mask Aplicar máscara de color Edit Path Editar trazado Mask Máscara Crop Marks Marcas de corte @@ -4670,6 +4674,10 @@ axis Both Ambos @msg a CMYK image has no transparency to set una imagen CMYK no tiene transparencia que establecer @msg every pixel matches the background: no image would be left todos los píxeles coinciden con el fondo: no quedaría ninguna imagen @msg an image of {w} × {h} pixels is too large to change una imagen de {w} × {h} píxeles es demasiado grande para cambiarla +@msg select an image with a color mask selecciona una imagen con una máscara de color +@msg the color mask's settings are out of range los ajustes de la máscara de color están fuera de rango +@msg a CMYK image can't take a color mask una imagen CMYK no puede tener una máscara de color +@msg the mask would hide every pixel la máscara ocultaría todos los píxeles @msg select an object in perspective selecciona un objeto en perspectiva @msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloreado completado; sin cambios: {locked} y {hidden} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..5af3c53ddd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -219,6 +219,10 @@ Corner Color Kulmien väri Contiguous Yhtenäinen Each Channel Jokainen kanava + Color Mask… Värimaski… + Color Mask Värimaski + Remove Color Mask Poista värimaski + Apply Color Mask Käytä värimaskia Edit Path Muokkaa polkua Mask Maski Rasterize… Rasteroi… @@ -4880,6 +4884,10 @@ imageTrace Outline Ääriviiva @msg a CMYK image has no transparency to set CMYK-kuvalla ei ole asetettavaa läpinäkyvyyttä @msg every pixel matches the background: no image would be left jokainen pikseli vastaa taustaa: kuvaa ei jäisi jäljelle @msg an image of {w} × {h} pixels is too large to change kuva, jonka koko on {w} × {h} pikseliä, on liian suuri muutettavaksi +@msg select an image with a color mask valitse kuva, jossa on värimaski +@msg the color mask's settings are out of range värimaskin asetukset ovat sallitun alueen ulkopuolella +@msg a CMYK image can't take a color mask CMYK-kuvalla ei voi olla värimaskia +@msg the mask would hide every pixel maski piilottaisi jokaisen pikselin @msg select an object in perspective valitse perspektiivissä oleva objekti @msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä) @msg Recolor completed; {locked} and {hidden} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {locked} ja {hidden} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..354a827e4f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -712,6 +712,10 @@ font-technology Variable Variables Corner Color Couleur des angles Contiguous Contigu Each Channel Chaque canal + Color Mask… Masque de couleur… + Color Mask Masque de couleur + Remove Color Mask Supprimer le masque de couleur + Apply Color Mask Appliquer le masque de couleur Edit Path Modifier le tracé Mask Masque Crop Marks Traits de coupe @@ -4659,6 +4663,10 @@ axis Both Les deux @msg a CMYK image has no transparency to set une image CMJN n’a pas de transparence à définir @msg every pixel matches the background: no image would be left chaque pixel correspond à l’arrière-plan : il ne resterait aucune image @msg an image of {w} × {h} pixels is too large to change une image de {w} × {h} pixels est trop grande pour être modifiée +@msg select an image with a color mask sélectionnez une image avec un masque de couleur +@msg the color mask's settings are out of range les réglages du masque de couleur sont hors limites +@msg a CMYK image can't take a color mask une image CMJN ne peut pas recevoir de masque de couleur +@msg the mask would hide every pixel le masque cacherait tous les pixels @msg select an object in perspective sélectionnez un objet en perspective @msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..472461e4b3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1119,6 +1119,10 @@ antiAlias Sharp शार्प Corner Color कोनों का रंग Contiguous सन्निहित Each Channel प्रत्येक चैनल + Color Mask… रंग मास्क… + Color Mask रंग मास्क + Remove Color Mask रंग मास्क हटाएँ + Apply Color Mask रंग मास्क लागू करें Crop Marks क्रॉप मार्क Crop To करने के लिए फसल Cross क्रॉस @@ -7458,6 +7462,10 @@ antiAlias Sharp शार्प @msg a CMYK image has no transparency to set CMYK इमेज में सेट करने के लिए कोई पारदर्शिता नहीं है @msg every pixel matches the background: no image would be left हर पिक्सेल पृष्ठभूमि से मेल खाता है: कोई इमेज नहीं बचेगी @msg an image of {w} × {h} pixels is too large to change {w} × {h} पिक्सेल की इमेज बदलने के लिए बहुत बड़ी है +@msg select an image with a color mask रंग मास्क वाली एक इमेज चुनें +@msg the color mask's settings are out of range रंग मास्क की सेटिंग सीमा से बाहर हैं +@msg a CMYK image can't take a color mask CMYK इमेज पर रंग मास्क नहीं लगाया जा सकता +@msg the mask would hide every pixel मास्क हर पिक्सेल को छिपा देगा @msg select an image (or an Image Trace group) एक इमेज सिलेक्ट करें (या एक इमेज ट्रेस समूह) @msg select an Image Trace object एक इमेज ट्रेस ऑब्जेक्ट चुनें @msg select an image, or pass id एक इमेज सिलेक्ट करें, या आईडी पास करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..18e489625a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -911,6 +911,10 @@ font-technology Variable Változó Corner Color Sarokszín Contiguous Összefüggő Each Channel Minden csatorna + Color Mask… Színmaszk… + Color Mask Színmaszk + Remove Color Mask Színmaszk eltávolítása + Apply Color Mask Színmaszk alkalmazása Edit Path Útvonal szerkesztése Crop Marks Vágójelek Crop To Körülvágás @@ -5421,6 +5425,10 @@ imageTrace Outline Körvonal @msg a CMYK image has no transparency to set a CMYK képnek nincs beállítható átlátszósága @msg every pixel matches the background: no image would be left minden képpont egyezik a háttérrel: nem maradna kép @msg an image of {w} × {h} pixels is too large to change egy {w} × {h} pixeles kép túl nagy a módosításhoz +@msg select an image with a color mask jelölj ki egy színmaszkos képet +@msg the color mask's settings are out of range a színmaszk beállításai a tartományon kívül esnek +@msg a CMYK image can't take a color mask CMYK képre nem tehető színmaszk +@msg the mask would hide every pixel a maszk minden képpontot elrejtene @msg select an Image Trace object jelölj ki egy képvektorizálási objektumot @msg select an object in perspective jelölj ki egy perspektivikus objektumot @msg select artwork (or a colour group) to recolor jelölj ki átszínezendő grafikát (vagy színcsoportot) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 87c3f55cd5..0e2400b19a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -199,6 +199,10 @@ Corner Color Warna sudut Contiguous Bersambung Each Channel Setiap kanal + Color Mask… Masker warna… + Color Mask Masker warna + Remove Color Mask Hapus masker warna + Apply Color Mask Terapkan masker warna Edit Path Edit jalur Mask Masker Rasterize… Rasterisasi… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..4e935d60bc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -710,6 +710,10 @@ font-technology Variable Variabili Corner Color Colore degli angoli Contiguous Contiguo Each Channel Ogni canale + Color Mask… Maschera colore… + Color Mask Maschera colore + Remove Color Mask Rimuovi maschera colore + Apply Color Mask Applica maschera colore Edit Path Modifica tracciato Mask Maschera Crop Marks Segni di ritaglio @@ -4721,6 +4725,10 @@ axis Both Entrambi @msg a CMYK image has no transparency to set un'immagine CMYK non ha trasparenza da impostare @msg every pixel matches the background: no image would be left ogni pixel corrisponde allo sfondo: non resterebbe alcuna immagine @msg an image of {w} × {h} pixels is too large to change un'immagine di {w} × {h} pixel è troppo grande per essere modificata +@msg select an image with a color mask seleziona un'immagine con una maschera colore +@msg the color mask's settings are out of range le impostazioni della maschera colore sono fuori intervallo +@msg a CMYK image can't take a color mask un'immagine CMYK non può avere una maschera colore +@msg the mask would hide every pixel la maschera nasconderebbe ogni pixel @msg select an object in perspective seleziona un oggetto in prospettiva @msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare @msg Recolor completed; {locked} and {hidden} were left unchanged Ricolorazione completata; senza modifiche: {locked} e {hidden} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..1d2c05e7f3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -241,6 +241,10 @@ digits Malayalam マラヤーラム数字 Corner Color 角の色 Contiguous 隣接 Each Channel 各チャンネル + Color Mask… カラーマスク… + Color Mask カラーマスク + Remove Color Mask カラーマスクを削除 + Apply Color Mask カラーマスクを適用 Edit Path パスを編集 Mask マスク Rasterize… ラスタライズ… @@ -4923,6 +4927,10 @@ swatch Bright Violet ビビッドバイオレット @msg a CMYK image has no transparency to set CMYK 画像には設定できる透明部分がありません @msg every pixel matches the background: no image would be left すべてのピクセルが背景に一致します: 画像が残りません @msg an image of {w} × {h} pixels is too large to change {w} × {h} ピクセルの画像は大きすぎて変更できません +@msg select an image with a color mask カラーマスクのある画像を選択してください +@msg the color mask's settings are out of range カラーマスクの設定が範囲外です +@msg a CMYK image can't take a color mask CMYK 画像にはカラーマスクを設定できません +@msg the mask would hide every pixel マスクがすべてのピクセルを隠してしまいます @msg the document has no artboards ドキュメントにアートボードがありません @msg tool (a tool id) is required tool (ツール ID) を指定してください @msg unknown tool `{t}` `{t}` は不明なツールです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..e78aff2a60 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -171,6 +171,10 @@ Corner Color კუთხეების ფერი Contiguous მომიჯნავე Each Channel თითოეული არხი + Color Mask… ფერის ნიღაბი… + Color Mask ფერის ნიღაბი + Remove Color Mask ფერის ნიღბის მოშორება + Apply Color Mask ფერის ნიღბის გამოყენება Mask ნიღაბი Rasterize… რასტრირება… Create Gradient Mesh… გრადიენტული ბადის შექმნა… @@ -4418,6 +4422,10 @@ font-technology Variable ცვლადი @msg a CMYK image has no transparency to set CMYK გამოსახულებას არ აქვს დასაყენებელი გამჭვირვალობა @msg every pixel matches the background: no image would be left ყველა პიქსელი ემთხვევა ფონს: გამოსახულება აღარ დარჩებოდა @msg an image of {w} × {h} pixels is too large to change {w} × {h} პიქსელის გამოსახულება შესაცვლელად ზედმეტად დიდია +@msg select an image with a color mask მონიშნეთ ფერის ნიღბიანი გამოსახულება +@msg the color mask's settings are out of range ფერის ნიღბის პარამეტრები დასაშვებ დიაპაზონს სცდება +@msg a CMYK image can't take a color mask CMYK გამოსახულებას ფერის ნიღაბი ვერ დაედება +@msg the mask would hide every pixel ნიღაბი ყველა პიქსელს დამალავდა @msg select an object in perspective მონიშნეთ ობიექტი პერსპექტივაში @msg select artwork (or a colour group) to recolor მონიშნეთ ხელახლა შესაფერადებელი ნახატი (ან ფერთა ჯგუფი) @msg Recolor completed; {locked} and {hidden} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {locked} და {hidden} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..07c1cae081 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -234,6 +234,10 @@ digits Malayalam 말라얄람어 Corner Color 모퉁이 색상 Contiguous 인접 Each Channel 각 채널 + Color Mask… 색상 마스크… + Color Mask 색상 마스크 + Remove Color Mask 색상 마스크 제거 + Apply Color Mask 색상 마스크 적용 Edit Path 패스 편집 Mask 마스크 Rasterize… 래스터화… @@ -4850,6 +4854,10 @@ theme Appearance 외관 @msg a CMYK image has no transparency to set CMYK 이미지에는 설정할 투명도가 없습니다 @msg every pixel matches the background: no image would be left 모든 픽셀이 배경과 일치합니다: 남는 이미지가 없습니다 @msg an image of {w} × {h} pixels is too large to change {w} × {h}픽셀 이미지는 너무 커서 변경할 수 없습니다 +@msg select an image with a color mask 색상 마스크가 있는 이미지를 선택하세요 +@msg the color mask's settings are out of range 색상 마스크 설정이 범위를 벗어났습니다 +@msg a CMYK image can't take a color mask CMYK 이미지에는 색상 마스크를 적용할 수 없습니다 +@msg the mask would hide every pixel 마스크가 모든 픽셀을 숨기게 됩니다 @msg the document has no artboards 문서에 아트보드가 없습니다 @msg tool (a tool id) is required 도구(도구 id)가 필요합니다 @msg unknown tool `{t}` 알 수 없는 도구 `{t}` diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4f9d484155..dd79fa8c52 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -213,6 +213,10 @@ Corner Color Hoekkleur Contiguous Aaneengesloten Each Channel Elk kanaal + Color Mask… Kleurmasker… + Color Mask Kleurmasker + Remove Color Mask Kleurmasker verwijderen + Apply Color Mask Kleurmasker toepassen Edit Path Pad bewerken Mask Masker Rasterize… Rasteriseren… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..e3c5814d1d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -176,6 +176,10 @@ Corner Color Kolor narożników Contiguous Ciągły Each Channel Każdy kanał + Color Mask… Maska koloru… + Color Mask Maska koloru + Remove Color Mask Usuń maskę koloru + Apply Color Mask Zastosuj maskę koloru Edit Path Edytuj ścieżkę Mask Maska Rasterize… Rasteryzuj… @@ -4804,6 +4808,10 @@ imageTrace Outline Kontur @msg a CMYK image has no transparency to set obraz CMYK nie ma przezroczystości, którą można ustawić @msg every pixel matches the background: no image would be left każdy piksel pasuje do tła: nie zostałby żaden obraz @msg an image of {w} × {h} pixels is too large to change obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go zmienić +@msg select an image with a color mask zaznacz obraz z maską koloru +@msg the color mask's settings are out of range ustawienia maski koloru są poza zakresem +@msg a CMYK image can't take a color mask obraz CMYK nie może mieć maski koloru +@msg the mask would hide every pixel maska ukryłaby każdy piksel @msg select an object in perspective zaznacz obiekt w perspektywie @msg select artwork (or a colour group) to recolor zaznacz kompozycję (lub grupę kolorów), aby zmienić kolory @msg Recolor completed; {locked} and {hidden} were left unchanged Zmiana kolorów zakończona; bez zmian: {locked} i {hidden} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..3784be4eec 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -636,6 +636,10 @@ antiAlias Sharp Nítido Corner Color Cor dos Cantos Contiguous Contíguo Each Channel Cada Canal + Color Mask… Máscara de Cor… + Color Mask Máscara de Cor + Remove Color Mask Remover Máscara de Cor + Apply Color Mask Aplicar Máscara de Cor Edit Path Editar caminho Mask Máscara Crop Marks Marcas de Corte @@ -4207,6 +4211,10 @@ axis Both Ambos @msg a CMYK image has no transparency to set uma imagem CMYK não tem transparência para definir @msg every pixel matches the background: no image would be left todos os pixels correspondem ao fundo: nenhuma imagem restaria @msg an image of {w} × {h} pixels is too large to change uma imagem de {w} × {h} pixels é grande demais para ser alterada +@msg select an image with a color mask selecione uma imagem com uma máscara de cor +@msg the color mask's settings are out of range as configurações da máscara de cor estão fora do intervalo +@msg a CMYK image can't take a color mask uma imagem CMYK não pode ter uma máscara de cor +@msg the mask would hide every pixel a máscara ocultaria todos os pixels @msg select an object in perspective selecione um objeto em perspectiva @msg select artwork (or a colour group) to recolor selecione uma arte-final (ou um grupo de cores) para recolorir @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloração concluída; sem alteração: {locked} e {hidden} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..e2e3cd548a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -708,6 +708,10 @@ font-technology Variable Вариативные Corner Color Цвет углов Contiguous Смежные Each Channel Каждый канал + Color Mask… Цветовая маска… + Color Mask Цветовая маска + Remove Color Mask Удалить цветовую маску + Apply Color Mask Применить цветовую маску Edit Path Редактировать контур Mask Маска Crop Marks Метки обрезки @@ -4707,6 +4711,10 @@ axis Both Оба @msg a CMYK image has no transparency to set у изображения CMYK нет прозрачности, которую можно задать @msg every pixel matches the background: no image would be left каждый пиксель совпадает с фоном: от изображения ничего не осталось бы @msg an image of {w} × {h} pixels is too large to change изображение размером {w} × {h} пикселей слишком велико для изменения +@msg select an image with a color mask выделите изображение с цветовой маской +@msg the color mask's settings are out of range настройки цветовой маски вне допустимого диапазона +@msg a CMYK image can't take a color mask к изображению CMYK нельзя применить цветовую маску +@msg the mask would hide every pixel маска скрыла бы все пиксели @msg select an object in perspective выделите объект в перспективе @msg select artwork (or a colour group) to recolor выделите рисунок (или цветовую группу) для перекрашивания @msg Recolor completed; {locked} and {hidden} were left unchanged Перекрашивание завершено; без изменений: {locked} и {hidden} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..b89236b45c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -698,6 +698,10 @@ font-technology Variable Variabla Corner Color Hörnfärg Contiguous Sammanhängande Each Channel Varje kanal + Color Mask… Färgmask… + Color Mask Färgmask + Remove Color Mask Ta bort färgmask + Apply Color Mask Tillämpa färgmask Edit Path Redigera bana Mask Maskera Crop Marks Skärmärken @@ -4567,6 +4571,10 @@ axis Both Båda @msg a CMYK image has no transparency to set en CMYK-bild har ingen transparens att ställa in @msg every pixel matches the background: no image would be left varje pixel matchar bakgrunden: ingen bild skulle bli kvar @msg an image of {w} × {h} pixels is too large to change en bild på {w} × {h} pixlar är för stor för att ändras +@msg select an image with a color mask markera en bild med en färgmask +@msg the color mask's settings are out of range färgmaskens inställningar ligger utanför intervallet +@msg a CMYK image can't take a color mask en CMYK-bild kan inte ha en färgmask +@msg the mask would hide every pixel masken skulle dölja varje pixel @msg select an object in perspective markera ett objekt i perspektiv @msg select artwork (or a colour group) to recolor markera grafik (eller en färggrupp) att färga om @msg Recolor completed; {locked} and {hidden} were left unchanged Omfärgning klar; utan ändring: {locked} och {hidden} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..8000ce5b2f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -690,6 +690,10 @@ font-technology Variable Değişken Corner Color Köşe Rengi Contiguous Bitişik Each Channel Her Kanal + Color Mask… Renk Maskesi… + Color Mask Renk Maskesi + Remove Color Mask Renk Maskesini Kaldır + Apply Color Mask Renk Maskesini Uygula Mask Maske Crop Marks Kırpma İşaretleri Crop To Kırpma Hedefi @@ -4420,6 +4424,10 @@ axis Both Her İkisi @msg a CMYK image has no transparency to set CMYK görüntüsünde ayarlanacak saydamlık yok @msg every pixel matches the background: no image would be left her piksel arka planla eşleşiyor: hiç görüntü kalmaz @msg an image of {w} × {h} pixels is too large to change {w} × {h} piksellik bir görüntü değiştirilemeyecek kadar büyük +@msg select an image with a color mask renk maskesi olan bir resim seç +@msg the color mask's settings are out of range renk maskesinin ayarları izin verilen aralığın dışında +@msg a CMYK image can't take a color mask CMYK görüntüsüne renk maskesi uygulanamaz +@msg the mask would hide every pixel maske her pikseli gizlerdi @msg select an object in perspective perspektifte bir nesne seç @msg select artwork (or a colour group) to recolor yeniden renklendirilecek resmi (veya bir renk grubunu) seçin @msg Recolor completed; {locked} and {hidden} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {locked} ve {hidden} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..7209578cba 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -704,6 +704,10 @@ font-technology Variable Варіативні Corner Color Колір кутів Contiguous Суміжні Each Channel Кожен канал + Color Mask… Колірна маска… + Color Mask Колірна маска + Remove Color Mask Видалити колірну маску + Apply Color Mask Застосувати колірну маску Edit Path Редагувати контур Mask Маска Crop Marks Мітки обрізання @@ -4705,6 +4709,10 @@ axis Both Обидві @msg a CMYK image has no transparency to set зображення CMYK не має прозорості, яку можна задати @msg every pixel matches the background: no image would be left кожен піксель збігається з фоном: від зображення нічого не залишилося б @msg an image of {w} × {h} pixels is too large to change зображення розміром {w} × {h} пікселів завелике для зміни +@msg select an image with a color mask виділіть зображення з колірною маскою +@msg the color mask's settings are out of range параметри колірної маски поза допустимим діапазоном +@msg a CMYK image can't take a color mask до зображення CMYK не можна застосувати колірну маску +@msg the mask would hide every pixel маска приховала б усі пікселі @msg select an object in perspective виділіть об’єкт у перспективі @msg select artwork (or a colour group) to recolor виділіть малюнок (або колірну групу) для перефарбовування @msg Recolor completed; {locked} and {hidden} were left unchanged Перефарбовування завершено; без змін: {locked} і {hidden} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..93b794e127 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -811,6 +811,10 @@ font-technology Variable Biến thiên Corner Color Màu góc Contiguous Liền kề Each Channel Từng kênh + Color Mask… Mặt nạ màu… + Color Mask Mặt nạ màu + Remove Color Mask Xóa mặt nạ màu + Apply Color Mask Áp dụng mặt nạ màu Edit Path Sửa đường dẫn Crop Marks Dấu cắt xén Crop To Cắt xén đến @@ -5084,6 +5088,10 @@ theme Appearance Giao diện @msg a CMYK image has no transparency to set ảnh CMYK không có độ trong suốt để đặt @msg every pixel matches the background: no image would be left mọi điểm ảnh đều khớp với nền: sẽ không còn lại ảnh nào @msg an image of {w} × {h} pixels is too large to change ảnh {w} × {h} điểm ảnh quá lớn nên không thể thay đổi +@msg select an image with a color mask hãy chọn ảnh có mặt nạ màu +@msg the color mask's settings are out of range cài đặt của mặt nạ màu nằm ngoài phạm vi cho phép +@msg a CMYK image can't take a color mask ảnh CMYK không thể có mặt nạ màu +@msg the mask would hide every pixel mặt nạ sẽ ẩn mọi điểm ảnh @msg select an Image Trace object hãy chọn đối tượng vector hóa ảnh @msg select an object in perspective hãy chọn đối tượng trong phối cảnh @msg select artwork (or a colour group) to recolor hãy chọn tác phẩm (hoặc nhóm màu) để tô lại diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..89821b1212 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -655,6 +655,10 @@ antiAlias Sharp 锐利 Corner Color 转角颜色 Contiguous 连续 Each Channel 每个通道 + Color Mask… 颜色蒙版… + Color Mask 颜色蒙版 + Remove Color Mask 移除颜色蒙版 + Apply Color Mask 应用颜色蒙版 Edit Path 编辑路径 Mask 蒙版 Crop Marks 裁切标记 @@ -4615,6 +4619,10 @@ imageTrace Outline 轮廓 @msg a CMYK image has no transparency to set CMYK 图像没有可设置的透明度 @msg every pixel matches the background: no image would be left 每个像素都与背景匹配:不会留下任何图像 @msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的图像过大,无法更改 +@msg select an image with a color mask 请选择一个带颜色蒙版的图像 +@msg the color mask's settings are out of range 颜色蒙版的设置超出范围 +@msg a CMYK image can't take a color mask CMYK 图像不能使用颜色蒙版 +@msg the mask would hide every pixel 蒙版会隐藏所有像素 @msg the document has no artboards 文档没有画板 @msg tool (a tool id) is required 需要 tool (工具 id) @msg unknown tool `{t}` 未知工具 `{t}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..93e931564d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -672,6 +672,10 @@ font-technology Variable 可變 Corner Color 轉角顏色 Contiguous 連續 Each Channel 每個通道 + Color Mask… 顏色遮色片… + Color Mask 顏色遮色片 + Remove Color Mask 移除顏色遮色片 + Apply Color Mask 套用顏色遮色片 Edit Path 編輯路徑 Mask 遮色片 Crop Marks 裁切標記 @@ -2408,6 +2412,10 @@ file-filter Templates 範本 @msg a CMYK image has no transparency to set CMYK 影像沒有可設定的透明度 @msg every pixel matches the background: no image would be left 每個像素都與背景相符:不會留下任何影像 @msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的影像過大,無法變更 +@msg select an image with a color mask 請選取帶有顏色遮色片的影像 +@msg the color mask's settings are out of range 顏色遮色片的設定超出範圍 +@msg a CMYK image can't take a color mask CMYK 影像無法使用顏色遮色片 +@msg the mask would hide every pixel 遮色片會隱藏所有像素 @msg the document has no artboards 文件沒有工作區域 @msg tool (a tool id) is required 需要 tool (工具 id) @msg unknown tool `{t}` 未知的工具 `{t}` diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index ab530b803c..9d30b2a5b6 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -29,12 +29,13 @@ pub(crate) fn with_held_keys(target: (String, Value), held: egui::Modifiers) -> /// The dialog of command `id` that shows the selection's current values (its query): its heading /// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they -/// are. +/// are. Color Mask opens on the selected image's mask settings (the defaults without one). fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { Some(match id { // Whether the text overflows, and Shrink Text's factor, are read-only facts. "text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]), "type.pathOptions" => ("Type on a Path Options", &["start", "end"]), + crate::addons::image::COLOR_MASK => ("Color Mask", &["masked"]), _ => return None, }) } diff --git a/docs/mcp.md b/docs/mcp.md index 4ac7a10930..6099d41044 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9793,8 +9793,9 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la ## Image Tools add-on -Image Tools is a built-in add-on for placed images: its command is `addon.image.removeBackground`, and in the desktop -app it lives in Object › Plug-ins › Image Tools. It is a VectorCraft addition. +Image Tools is a built-in add-on for placed images: its commands are `addon.image.removeBackground`, +`addon.image.colorMask`, `addon.image.removeColorMask` and `addon.image.applyColorMask`, and in the desktop app it +lives in Object › Plug-ins › Image Tools. It is a VectorCraft addition. ### Remove Background @@ -9826,9 +9827,11 @@ change in one undo step, and an image that `ids` names twice changes once. An im as it is, and when none changes the command adds no undo step and returns `changed: 0`. `cleared` counts the pixels made fully clear in the images that changed. The command refuses, and changes nothing, when an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance while it is -worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), or every pixel of an -image would be cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas -shows the result for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, +worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), every pixel of an +image would be cleared, or a grayscale image colorized with a process color would get an alpha channel (a colorize +color on a grayscale image with transparency is a spot color, black or white). Remove Background… opens the command's +dialog with Preview off; with Preview on, the canvas shows the result for the values in the dialog, and Cancel puts the +image back. While a dialog previews the command, the session keeps the decoded pixels of the images it changes (at most 128 MiB, or 32 MiB in the web app), so the next preview step does not decode them again; it releases them when the dialog closes with OK or Cancel. A run that is not a preview step does not keep them. @@ -9839,6 +9842,53 @@ a preview step does not keep them. {"name":"run_command","arguments":{"command":"addon.image.removeBackground","params":{"background":"corners","contiguous":false,"match":"channels","tolerance":12.55,"softness":0}}} ``` +### Color Mask + +`addon.image.colorMask` hides colors of the selected images, or of the images in `ids`, and leaves their pixels as +they are. It takes `background`, `colors`, `contiguous`, `match`, `tolerance` and `softness` with the same values, +defaults and refusals as `addon.image.removeBackground`, and matches colors the same way. The image keeps its own +pixels. The mask is kept with the image object and saved with the document: its settings, and a PNG of the image's +pixels with the hidden colors made clear (16-bit and gray images keep their depth and channels, with the source's color +profile and resolution). PDF's color key masking hides chosen colors of an image in a similar way +([ISO 32000-1 §8.9.6.4](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)). + +- The canvas, printing and separations, PDF (PDF/X-1a and PDF/X-3 flatten the hidden pixels as transparency), SVG, + EPS, the raster formats, DXF, EMF, WMF, the legacy .ai, the .ai editing data, copy and paste, and placing a document + 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. +- 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. +- Running the command again on a masked image replaces its mask. All the images change in one undo step. An image whose + mask does not change is left as it is, and when none changes the command adds no undo step and returns `changed: 0`. + `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. +- 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. +- With none of `background`, `colors`, `contiguous`, `match`, `tolerance` and `softness` given, nothing changes, and + the command returns the mask settings of the first image (the defaults when it has no mask) with `masked`. Color + Mask… opens the command's dialog on those settings, with a Preview that works as Remove Background's does. + +→ `{changed, ids, hidden}`, or without settings `{background, colors, contiguous, match, tolerance, softness, masked}` + +`addon.image.removeColorMask` takes the mask off the selected images, or the images in `ids`, in one undo step, and +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 +enabled only when a selected image has a mask. → `{changed, ids}` + +```json +{"name":"run_command","arguments":{"command":"addon.image.colorMask","params":{"background":"colors","colors":"#00ff00","contiguous":false,"tolerance":15,"softness":10}}} +{"name":"run_command","arguments":{"command":"addon.image.applyColorMask","params":{}}} +``` + ## Type Tools add-on Type Tools is a built-in add-on for type: its commands are `addon.type.textTweaker` and `addon.type.releasePath`, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..9672c6e9b1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1999,7 +1999,11 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance, results with no pixel left and grayscale images colorized with a process color are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). + +## Image Tools add-on: Color Mask (2026-11-12) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Color Mask… (`addon.image.colorMask`) hides colors of the selected images while their pixels stay as they are. It matches colors as Remove Background does (edge, corner or picked colors, Contiguous, Match, Tolerance and Softness, VectorCraft's own values). The mask's settings and the masked pixels are kept with the image object and saved with the document. The canvas (clip groups and white-shape opacity masks included), printing and separations, PDF (PDF/X-1a flattens the hidden pixels), SVG, EPS, PNG, DXF, EMF, WMF, the legacy .ai, the .ai editing data, copy and paste, and placing a document show the masked pixels. A linked image stays linked, and outputs that would refer to its file write the masked pixels instead. A masked grayscale image takes only a spot, black or white colorize color. The dialog opens on the image's mask settings. Remove Color Mask takes the mask off, and Apply Color Mask makes it part of the image's pixels. Each is one undo step, and none is recorded when nothing changes. When the image's own pixels are replaced (a link updated or read again on open, the image cropped or recolored), the mask is made again for the new pixels. A click anywhere in the image's frame selects it. Tests: `cmd::addons::image::mask::tests` in `vectorcraft-engine` (hidden colors drawn, pixels kept, edit, report, remove and apply, the outputs listed, save and open, copy and paste, placing a document, a linked image, cropped and replaced pixels, colorize colors, refusals), `color_mask::tests` and `placed_document::tests` in `vectorcraft-doc`, the separations and CMYK soft-mask tests in `vectorcraft-pdf`, and `addons::image::tests::color_mask_previews_keeps_its_settings_and_comes_off` in `vectorcraft-ui-egui`. Commands: `docs/mcp.md` › Image Tools add-on › Color Mask. ## Color Effects add-on: Find Nearest Swatch (2026-11-01)