Merge pull request #6881 from dotCooCoo/feat/image-color-mask

Image Tools: Color Mask hides chosen colors of an image without changing its pixels
This commit is contained in:
BFlat
2026-11-13 10:27:04 +07:00
committed by GitHub
75 changed files with 2458 additions and 154 deletions
+1 -1
View File
@@ -672,7 +672,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 {
+197
View File
@@ -0,0 +1,197 @@
//! 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. A record without it reads
/// as `true`, the command's default.
#[serde(default = "default_contiguous")]
pub contiguous: bool,
#[serde(default, rename = "match")]
pub measure: MaskMatch,
/// How near a color must be to be hidden, 0 to 100 (see [`MaskMatch`]). A record without it
/// reads as 10, the command's default.
#[serde(default = "default_percent")]
pub tolerance: f64,
/// The band past the tolerance over which pixels fade from hidden to shown, 0 to 100. A record
/// without it reads as 10, the command's default.
#[serde(default = "default_percent")]
pub softness: f64,
}
fn default_contiguous() -> bool {
true
}
fn default_percent() -> f64 {
10.0
}
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());
}
/// A record that leaves out settings reads them as the command's defaults: edge color,
/// contiguous, distance, tolerance and softness 10.
#[test]
fn a_record_without_settings_reads_the_defaults() {
let m: ColorMask = serde_json::from_value(serde_json::json!({"key": "masked", "from": "own"})).unwrap();
assert_eq!(
(m.background, m.colors.len(), m.contiguous, m.measure, m.tolerance, m.softness),
(MaskBackground::Edges, 0, true, MaskMatch::Distance, 10.0, 10.0)
);
}
fn unreachable_kind() -> ! {
panic!("an image node")
}
}
+1
View File
@@ -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;
+4 -1
View File
@@ -206,12 +206,14 @@ pub fn mesh_warp_cost(cells: usize) -> usize {
/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the
/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past
/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the
/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name,
/// color `n` colorizes a grayscale image with, and `n`'s color mask. The result is a group with `n`'s id, name,
/// transparency, opacity mask and appearance.
pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node {
let k = cells.max(1);
// A grayscale image's fill color colorizes each piece; the group of pieces takes none.
let colorize = n.extras().colorize.clone();
// Each piece shows what the image shows: its color mask's masked pixels.
let color_mask = n.extras().color_mask.clone();
let (pw, ph) = (im.width as f64, im.height as f64);
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
let mut pieces = Vec::with_capacity(2 * k * k);
@@ -236,6 +238,7 @@ pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, c
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
image.extras_mut().pixelated = pixelated;
image.extras_mut().colorize = colorize.clone();
image.extras_mut().color_mask = color_mask.clone();
if masked {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
+4
View File
@@ -261,6 +261,10 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet<usize>) -> u
b += ALLOC + size_of::<NodeExtras>();
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::<crate::color_mask::ColorMask>() + m.key.capacity() + m.from.capacity() + m.colors.capacity() * 3);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
if e.intertwine_tiers.capacity() > 0 {
+4
View File
@@ -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<Box<crate::color_mask::ColorMask>>,
}
/// 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
+5
View File
@@ -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<Box<crate::color_mask::ColorMask>>,
}
impl From<NodeWire> for Node {
@@ -269,6 +273,7 @@ impl From<NodeWire> 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,
+30 -1
View File
@@ -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"))));
+64 -7
View File
@@ -435,11 +435,19 @@ impl ColorVisitor for Live<'_, '_> {
/// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects
/// are gone from the result; embedded images take the keys `images` gives. `swatches` tells the
/// document's global and spot swatches apart ([`crate::addons::duotone::swatch_kind`]).
pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images: &mut ImageHook<'_>) -> Option<Node> {
/// `has_image` tells whether the document holds an image: an image whose color mask's pixels are
/// missing is recoloured from its own pixels, as it is drawn without the adjustment.
pub fn adjust(
n: &Node,
outer: Option<&ColorMap>,
swatches: &SwatchKinds,
images: &mut ImageHook<'_>,
has_image: &dyn Fn(&str) -> bool,
) -> Option<Node> {
let mut m: Option<Node> = None;
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some(new) = adjust(c, None, swatches, images)
if let Some(new) = adjust(c, None, swatches, images, has_image)
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
{
*slot = Arc::new(new);
@@ -462,11 +470,57 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images
if let Some(map) = map {
let mm = m.get_or_insert_with(|| n.clone());
mm.appearance.effects.retain(|e| !is_adjustment(&e.id));
show_color_masks(mm, has_image);
recolor_node(mm, Reach::ALL, &mut Live { map: &map, swatches, images });
}
m
}
/// Whether `n` is an image that [`recolor_node`] recolours (embedded, not colorized, without
/// pixels of its own) with a color mask made from its pixels.
fn recoloured_with_mask(n: &Node) -> bool {
match (&n.kind, Document::color_mask_of(n)) {
(NodeKind::Image(im), Some(m)) => im.link.is_none() && n.extras().colorize.is_none() && m.made_for(im),
_ => false,
}
}
/// Whether `n` is or holds an image [`recoloured_with_mask`] counts.
fn holds_recoloured_with_mask(n: &Node) -> bool {
recoloured_with_mask(n) || n.children().is_some_and(|ch| ch.iter().any(|c| holds_recoloured_with_mask(c)))
}
/// Give each image in `n` that [`recoloured_with_mask`] counts its masked pixels as its own,
/// without the mask, so [`recolor_node`] recolours the pixels it shows and the colors the mask
/// hides stay hidden. An image whose masked pixels the document does not hold (`has_image`) is
/// drawn from its own pixels ([`Document::shown_image`]): it keeps its key and loses the mask, so
/// the recoloured copy is drawn the same way.
fn show_color_masks(n: &mut Node, has_image: &dyn Fn(&str) -> bool) {
if recoloured_with_mask(n)
&& let Some(masked) = Document::color_mask_of(n).map(|m| m.key.clone())
{
if let NodeKind::Image(im) = &mut n.kind
&& has_image(&masked)
{
im.key = masked;
}
n.extras_mut().color_mask = None;
}
for c in n.children_mut().into_iter().flatten() {
if holds_recoloured_with_mask(c) {
show_color_masks(Arc::make_mut(c), has_image);
}
}
}
/// The keys of the masked pixels of the color masks in `n` and its members.
fn color_mask_keys<'a>(n: &'a Node, out: &mut Vec<&'a str>) {
out.extend(Document::color_mask_of(n).map(|m| m.key.as_str()));
for c in n.children().into_iter().flatten() {
color_mask_keys(c, out);
}
}
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art`
/// when given, otherwise the symbol's art with the instance's own appearance changes.
@@ -482,13 +536,16 @@ pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option<Node>)
_ => n,
};
let kinds = crate::addons::duotone::swatch_kinds(d);
let mut masked = vec![];
color_mask_keys(src, &mut masked);
masked.retain(|k| d.images.contains_key(*k));
let mut hook = |key: &str, map: &ColorMap| -> Option<String> {
let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?;
let k = blob.content_key();
d.images.entry(k.clone()).or_insert(blob);
Some(k)
};
adjust(src, None, &|name| kinds.get(name).copied(), &mut hook)
adjust(src, None, &|name| kinds.get(name).copied(), &mut hook, &|key| masked.contains(&key))
}
#[cfg(test)]
@@ -551,17 +608,17 @@ mod tests {
let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]);
g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
assert!(has_adjustment(&g));
let out = adjust(&g, None, &|_| None, &mut |_, _| None).unwrap();
let out = adjust(&g, None, &|_| None, &mut |_, _| None, &|_| true).unwrap();
assert!(out.appearance.effects.is_empty());
for c in out.children().unwrap() {
assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
// A hidden effect does nothing; a stroke's own effect recolours just that stroke.
g.appearance.effects[0].visible = false;
assert!(!has_adjustment(&g) && adjust(&g, None, &|_| None, &mut |_, _| None).is_none());
assert!(!has_adjustment(&g) && adjust(&g, None, &|_| None, &mut |_, _| None, &|_| true).is_none());
let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true });
let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap();
let out = adjust(&p, None, &|_| None, &mut |_, _| None, &|_| true).unwrap();
assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff");
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
assert!(out.appearance.items[1].effects().is_empty());
@@ -576,7 +633,7 @@ mod tests {
let fill = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0);
let mut p = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill);
p.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap();
let out = adjust(&p, None, &|_| None, &mut |_, _| None, &|_| true).unwrap();
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient fill") };
// The points are what a freeform gradient draws from, not its stops.
let hexes: Vec<String> = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect();
+3 -2
View File
@@ -682,12 +682,13 @@ fn as_art_opts(
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
let mut image = Node::new(n.id, n.kind.clone());
// Its pixels draw as the image's do: colorized by a grayscale image's fill color, and
// smoothed or not.
// Its pixels draw as the image's do: colorized by a grayscale image's fill color,
// smoothed or not, and with its color mask's masked pixels.
{
let mut e = image.extras_mut();
e.colorize = n.extras().colorize.clone();
e.pixelated = n.extras().pixelated;
e.color_mask = n.extras().color_mask.clone();
}
let clipped = || {
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
+1 -1
View File
@@ -777,7 +777,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;
+1 -1
View File
@@ -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;
+112 -74
View File
@@ -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,12 +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::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process};
use super::super::paint::{PROCESS_ON_TRANSPARENCY, colorized_with_process};
use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param};
/// Remove Background's command.
@@ -35,7 +39,7 @@ const MAX_COLORS: usize = 16;
const WORK_BYTES: u64 = 10;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
let mut v = vec![cmd!(
"addon.image.removeBackground",
"Remove Background…",
["Object", "Plug-ins", "Image Tools"],
@@ -43,7 +47,9 @@ pub fn specs() -> Vec<CommandSpec> {
"{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.
@@ -79,50 +85,50 @@ pub(crate) struct Settings {
}
impl Settings {
/// The settings `p` gives, each checked.
fn read(p: &Value) -> Result<Self> {
/// The settings `p` gives to command `cmd`, each checked.
fn read(p: &Value, cmd: &str) -> Result<Self> {
let percent = |key: &str| -> Result<f64> {
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<Vec<[u8; 3]>> {
/// 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<Vec<[u8; 3]>> {
// 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::<Option<_>>().ok_or_else(colors_error)?,
_ => return Err(colors_error()),
Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::<Option<_>>().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.
@@ -135,7 +141,7 @@ fn rgb8(t: &str) -> Option<[u8; 3]> {
}
fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
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;
@@ -165,40 +171,15 @@ fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<Vec<(NodeId, Cleared, Affine)>> {
let images = images(s, p, C)?;
// The result has an alpha channel, which a grayscale image colorized with a process color
// can't take. The pixels that are cleared are checked, the image's own, not the ones a color
// mask shows.
let st = s.doc()?;
let ids: Vec<NodeId> = 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<NodeId> = 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(n) = st.doc.node(id) else { continue };
let NodeKind::Image(im) = &n.kind else { continue };
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
// The result has an alpha channel, which a grayscale image colorized with a process color
// can't take.
if gray_colorized_with_process(&st.doc, n).is_some() {
for (id, _, blob) in &images {
if blob.gray_source().is_some() && st.doc.node(*id).is_some_and(|n| colorized_with_process(&st.doc, n)) {
return Err(bad(C, PROCESS_ON_TRANSPARENCY));
}
// 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));
}
// Each image's pixels once, however many objects show them.
let mut cleared: HashMap<&str, Option<Cleared>> = HashMap::new();
@@ -207,7 +188,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
}
@@ -225,6 +206,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<Vec<(NodeId, &'a ImageObject, &'a ImageBlob)>> {
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<Vec<NodeId>> {
let st = s.doc()?;
let ids: Vec<NodeId> = 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<NodeId> = 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)]
@@ -352,16 +385,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<Option<Cleared>> {
/// 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<Option<Cleared>> {
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));
@@ -373,7 +406,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),
@@ -389,9 +426,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);
@@ -401,11 +438,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<Pixels>, bool, Option<Vec<u8>>)> {
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<Pixels>, bool, Option<Vec<u8>>)> {
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 _;
@@ -418,13 +456,13 @@ fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc<Pix
let deep = kind.bytes_per_pixel() > 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);
+443
View File
@@ -0,0 +1,443 @@
//! 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::{PROCESS_ON_TRANSPARENCY, colorized_with_process};
use super::super::super::{CommandSpec, Result, Session, bad, cmd};
use super::{Background, DecodedImages, MAX_COLORS, Match, Settings, clear, images, targets};
/// 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<CommandSpec> {
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 mask that would hide pixels of a grayscale image colorized with a process color (masked gray pixels have transparency, which only a spot color, black or white colorizes; each image is checked on the pixels its new mask shows) are refused. With none of background, colors, contiguous, match, tolerance and softness given, nothing changes and the mask settings of the first image (or the defaults when it has no mask) are returned → {changed: the images changed, ids, hidden: the pixels made fully clear in them} or {background, colors, contiguous, match, tolerance, softness, masked}",
super::has_image,
color_mask
),
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, and a mask made again for new pixels that would give a grayscale image colorized with a process color an alpha channel, are refused → {changed, ids}",
has_color_mask,
apply_color_mask
),
]
}
/// 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<Self> {
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<Option<(String, ImageBlob, u64)>> {
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<Value> {
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<u64> = changes.iter().map(|c| c.0.0).collect();
let hidden: u64 = changes.iter().map(|c| c.3).sum();
if !changes.is_empty() {
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));
}
}
Ok(())
})?;
}
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<ImageBlob>, u64);
fn mask_changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result<Vec<MaskChange>> {
let st = s.doc()?;
// Each image's pixels once, however many objects show them.
let mut made: HashMap<&str, (String, Option<ImageBlob>, 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;
}
// Masked pixels that hide something have an alpha channel, which a grayscale image
// colorized with a process color can't take. The pixels the new mask shows are checked,
// made from the image's own pixels, not the ones an earlier mask shows.
let shown = pixels.as_ref().or_else(|| st.doc.images.get(&mask.key));
if st.doc.node(id).is_some_and(|n| refused_colorize(&st.doc, n, shown)) {
return Err(bad(COLOR_MASK, PROCESS_ON_TRANSPARENCY));
}
let pixels = pixels.filter(|_| !st.doc.images.contains_key(&mask.key));
out.push((id, mask, pixels, hidden));
}
Ok(out)
}
/// Whether masked pixels `shown`, made for image node `n`, have transparency that `n`'s colorize
/// color can't take: gray pixels with an alpha channel and a process color
/// ([`colorized_with_process`]).
fn refused_colorize(doc: &Document, n: &Node, shown: Option<&ImageBlob>) -> bool {
shown.and_then(ImageBlob::gray_source).is_some_and(|g| g.alpha) && colorized_with_process(doc, n)
}
/// Color Mask without settings: the first image's mask settings, or the defaults. It reads them
/// for images a change would refuse too (CMYK, a linked image at its preview), so the dialog opens.
fn report(s: &Session, p: &Value) -> Result<Value> {
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::<Vec<_>>().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<Vec<NodeId>> {
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<Value> {
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::<Vec<_>>() }))
}
fn apply_color_mask(s: &mut Session, p: &Value) -> Result<Value> {
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 shown = st.doc.images.get(&mask.0.key).or(mask.1.as_ref());
if refused_colorize(&st.doc, n, shown) {
return Err(bad(APPLY_COLOR_MASK, PROCESS_ON_TRANSPARENCY));
}
let size = mask_size(shown, im.width, im.height);
made.push((*id, Some((mask, size))));
}
}
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::<Vec<_>>() }))
}
/// 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<Result<(ColorMask, Option<ImageBlob>)>> {
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<ImageBlob>)> {
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<Node>> = 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<Node>, old: Option<&Arc<Node>>) -> 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<HashMap<NodeId, &Arc<Node>>> = 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, the mask's settings and
/// whether a process color colorizes the image (an image shown by many objects is decoded once),
/// and the masked pixels to add to the document.
struct Refresh<'a> {
doc: &'a Document,
made: HashMap<(String, String, bool), 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, gray pixels with transparency under a process color) keeps its
/// settings and hides nothing: the image shows its own pixels, and setting the mask again
/// reports why.
fn made(&mut self, n: &Node) -> Option<ColorMask> {
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:?}"), colorized_with_process(self.doc, n));
if let Some(made) = self.made.get(&at) {
return Some(made.clone());
}
let hides_nothing = || ColorMask { key: im.key.clone(), from: im.key.clone(), ..m.clone() };
let mask = match fresh(self.doc, n, COLOR_MASK)? {
Ok((mask, blob)) if refused_colorize(self.doc, n, blob.as_ref().or_else(|| self.doc.images.get(&mask.key))) => hides_nothing(),
Ok((mask, blob)) => {
self.blobs.extend(blob.map(|b| (mask.key.clone(), b)));
mask
}
Err(_) => hides_nothing(),
};
self.made.insert(at, mask.clone());
Some(mask)
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -392,7 +392,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));
}
@@ -123,6 +123,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 {
@@ -663,6 +667,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 {
+5 -3
View File
@@ -884,8 +884,8 @@ fn blended(n: &Node) -> bool {
n.opacity < 1.0 || n.blend != BlendMode::Normal
}
/// Whether `n` (art of `d`) is or holds an image with see-through pixels (key in `keys`), a
/// colorize paint that `colorize` counts, opacity or a blending mode of its own or of a group in
/// Whether `n` (art of `d`) is or holds an image with see-through pixels (key in `keys`, a
/// color-masked image's masked pixels counted as its own), a colorize paint that `colorize` counts, opacity or a blending mode of its own or of a group in
/// `n`, or anywhere when `on`, also in the tile art of the pattern fills of the paths in `n`, down
/// to `levels` patterns. Hidden art doesn't count.
fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet<String>, colorize: &dyn Fn(&Document, &Node) -> bool, on: bool, levels: u32) -> bool {
@@ -894,7 +894,9 @@ fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet<String>, colorize:
}
let on = on || blended(n);
match &n.kind {
NodeKind::Image(im) => on || keys.contains(&im.key) || (n.extras().colorize.is_some() && colorize(d, n)),
NodeKind::Image(im) => {
on || keys.contains(&im.key) || keys.contains(&d.shown_image(n, im).key) || (n.extras().colorize.is_some() && colorize(d, n))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
n.appearance.items.iter().any(|i| pattern_fill_holds_transparent_image(d, i, keys, colorize, on, levels))
}
+4 -2
View File
@@ -185,7 +185,8 @@ fn expand_image_patterns(d: &mut Document) -> Result<()> {
}
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
/// [`Document::walk`] counts). These have see-through pixels or a colorize paint (except the
/// [`Document::walk`] counts). These have see-through pixels (a color-masked image's masked pixels
/// counted as its own) or a colorize paint (except the
/// colorized images the writer writes as image masks, clear pixels included,
/// [`vectorcraft_eps::writes_image_mask`]), or
/// have opacity or a blending mode of their own or, with `flatten`, of a group they are in.
@@ -197,7 +198,8 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
let mut at = 0;
d.walk(|n| {
if let NodeKind::Image(im) = &n.kind
&& (((keys.contains(&im.key) || n.extras().colorize.is_some()) && !vectorcraft_eps::writes_image_mask(d, n))
&& (((keys.contains(&im.key) || keys.contains(&d.shown_image(n, im).key) || n.extras().colorize.is_some())
&& !vectorcraft_eps::writes_image_mask(d, n))
|| n.opacity < 1.0
|| n.blend != BlendMode::Normal
|| (mode == SeeThrough::Flatten && transparent(d, n.id)))
+3 -2
View File
@@ -76,12 +76,13 @@ pub(crate) fn see_through_images(doc: &Document) -> BTreeSet<String> {
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<String>) -> 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))),
}
}
+2 -1
View File
@@ -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;
}
+2
View File
@@ -1060,6 +1060,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
}
+5
View File
@@ -1053,6 +1053,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
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) =
+13 -5
View File
@@ -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,
@@ -758,11 +760,17 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R
if was_ok { check_colorized(d) } else { Ok(()) }
}
/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color
/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel.
/// Is `n` an image colorized with a color that [`colorizes_transparency`] refuses? When its pixels
/// are grayscale or 1-bit, they can't have an alpha channel.
pub(super) fn colorized_with_process(d: &Document, n: &Node) -> bool {
matches!(n.kind, NodeKind::Image(_)) && n.extras().colorize.as_deref().is_some_and(|paint| !colorizes_transparency(d, paint))
}
/// How the pixels `n` shows are gray, when `n` is a grayscale or 1-bit image colorized with a
/// color that [`colorizes_transparency`] refuses ([`colorized_with_process`]).
pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option<GraySource> {
let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None };
if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) }
let NodeKind::Image(im) = &n.kind else { return None };
if colorized_with_process(d, n) { d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) } else { None }
}
/// Is `e` the refusal of [`check_colorized`]?
+6
View File
@@ -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));
}
});
}
@@ -34,7 +34,10 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.contour.set",
"addon.cutting.bridges",
"addon.cutting.weed",
"addon.image.applyColorMask",
"addon.image.colorMask",
"addon.image.removeBackground",
"addon.image.removeColorMask",
"addon.pathtools.addPoints",
"addon.pathtools.close",
"addon.pathtools.corner",
+2
View File
@@ -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);
+1 -1
View File
@@ -644,7 +644,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") {
+1 -1
View File
@@ -179,7 +179,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)))
}
+2 -1
View File
@@ -283,7 +283,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));
+2
View File
@@ -367,6 +367,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()) => {
+1 -1
View File
@@ -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
+3
View File
@@ -165,6 +165,9 @@ impl Exporter<'_> {
pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask, interpolate: bool) -> Option<Image> {
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) {
+4 -3
View File
@@ -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
+20 -11
View File
@@ -153,25 +153,34 @@ fn colorized_key(key: &str) -> String {
}
/// Point the colorized images in `n`'s subtree at [`colorized_key`], collecting their keys in
/// `keys`. An image whose pixels in `images` don't take its colorize paint
/// ([`ImageBlob::takes_colorize`]) loses the paint instead, and is separated as the image it shows.
/// `keys`. A colorized image with a color mask is separated as it shows: its masked pixels
/// ([`Document::shown_image`]). An image whose pixels it shows in `images` don't take its colorize
/// paint ([`ImageBlob::takes_colorize`]) loses the paint instead, and is separated as the image it
/// shows.
fn keep_colorized_pixels(n: &mut Arc<Node>, images: &BTreeMap<String, ImageBlob>, keys: &mut BTreeSet<String>) {
let colorized = |n: &Node| matches!(n.kind, NodeKind::Image(ref im) if im.pixels.is_none()) && n.extras().colorize.is_some();
if !colorized(n) && n.children().is_none_or(|ch| ch.is_empty()) {
return;
}
let node = Arc::make_mut(n);
if colorized(node)
&& let NodeKind::Image(im) = &node.kind
&& images.get(&im.key).is_some_and(|b| !b.takes_colorize())
{
let shown = match &node.kind {
NodeKind::Image(im) if colorized(node) => {
let mask = Document::color_mask_of(node).filter(|m| m.made_for(im) && images.contains_key(&m.key));
Some(mask.map_or(&im.key, |m| &m.key).clone())
}
_ => None,
};
if shown.as_ref().and_then(|key| images.get(key)).is_some_and(|b| !b.takes_colorize()) {
node.extras_mut().colorize = None;
}
if colorized(node)
&& let NodeKind::Image(im) = &mut node.kind
{
keys.insert(im.key.clone());
im.key = colorized_key(&im.key);
if let Some(key) = shown.filter(|_| colorized(node)) {
if let Some(e) = node.extras_if_any() {
e.color_mask = None;
}
if let NodeKind::Image(im) = &mut node.kind {
im.key = colorized_key(&key);
keys.insert(key);
}
}
if let Some(ch) = node.children_mut() {
for c in ch.iter_mut() {
+40 -1
View File
@@ -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<u8>, clip: bool, invert: bool) -> (Vec<u8>, Vec<Option<Vec<u8>>>) {
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<u8>, 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<u8>, clip: bool, invert: bool) -> (Vec<u8>, Vec<Optio
let n = Node::new(d.alloc_id(), image("img")).with_mask(Some(Box::new(mask)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// A Press Quality export of `d`: the file, and the decoded soft mask of each CMYK image (`None`:
/// it has none).
fn export_smasks(d: &Document) -> (Vec<u8>, Vec<Option<Vec<u8>>>) {
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]
+70
View File
@@ -168,6 +168,76 @@ 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 colorized image with a color mask keeps its colorize paint on the plates when its masked
/// pixels take it, as on the canvas, though its own pixels are a preview that doesn't.
#[test]
fn separations_colorize_masked_pixels_whose_own_pixels_are_a_preview() {
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));
let preview = Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap());
d.images.insert("pic".into(), ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) });
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!(n.extras().colorize.is_some(), "the masked pixels take the colorize paint");
assert_eq!(p.image_blob(im).and_then(|b| b.coverage()).unwrap().2, [0, 0], "separated as the masked pixels");
}
}
/// 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.
+14 -8
View File
@@ -662,14 +662,20 @@ impl Renderer {
// shortcut: art kept by `fx_arts` isn't made again when only a swatch's kind (spot or
// process) changes, which Duotone reads; editing the object makes it again.
let swatches = |name: &str| effects::addons::duotone::swatch_kind(doc, name);
let adjusted = effects::adjust(&art, outer, &swatches, &mut |key, map| {
let mut h = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&format!("{map:?}"), &mut h);
let hash = std::hash::Hasher::finish(&h);
let derived = format!("{key}\u{0}adjust\u{0}{hash:016x}");
wanted.push((key.to_string(), derived.clone(), hash, map.clone()));
Some(derived)
});
let adjusted = effects::adjust(
&art,
outer,
&swatches,
&mut |key, map| {
let mut h = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&format!("{map:?}"), &mut h);
let hash = std::hash::Hasher::finish(&h);
let derived = format!("{key}\u{0}adjust\u{0}{hash:016x}");
wanted.push((key.to_string(), derived.clone(), hash, map.clone()));
Some(derived)
},
&|key| doc.images.contains_key(key),
);
for (key, derived, hash, map) in wanted {
self.adjusted_image(&key, derived, hash, &map);
}
+1 -1
View File
@@ -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;
}
+6 -3
View File
@@ -250,7 +250,7 @@ impl Renderer {
&& let Some(region) = self.clip_of(&m.art)
&& shape_masked_image(a, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region), images::image_tint(f.doc, a, f.ink), a.extras().pixelated);
self.draw_image_in(ctx, f, &f.doc.shown_image(a, im), Some(&region), 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, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region), 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(&region), 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
+2
View File
@@ -767,6 +767,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()
+5 -1
View File
@@ -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();
+20 -8
View File
@@ -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<Item> {
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)]
+65 -3
View File
@@ -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<vectorcraft_doc::color_mask::ColorMask> {
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()))
}
+12 -6
View File
@@ -64,12 +64,16 @@ 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 do Image Tools › Remove Background and Cutting Tools › Weed
/// Box and Lines. (Cancel rolls a preview back whichever dialog started it.)
/// works the same way (#5532), and so do Image Tools › Remove Background and Color Mask and
/// Cutting Tools › Weed Box and Lines. (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 | crate::addons::cutting::WEED => true,
"type.pathOptions"
| crate::addons::typetools::RELEASE
| crate::addons::image::REMOVE_BACKGROUND
| crate::addons::image::COLOR_MASK
| crate::addons::cutting::WEED => true,
_ => false,
}
}
@@ -134,7 +138,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"],
_ => &[],
}
@@ -157,8 +163,8 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
("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,
}
}
+4
View File
@@ -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 Знаци за изрязване
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1011,6 +1011,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
@@ -5967,6 +5971,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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
@@ -5096,6 +5100,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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
@@ -4713,6 +4717,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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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 Σημάδια ξακρίσματος
@@ -4735,6 +4739,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}
Can't render this file because it is too large.
+8
View File
@@ -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
@@ -4788,6 +4792,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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
@@ -4996,6 +5000,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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
@@ -4777,6 +4781,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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -1129,6 +1129,10 @@ antiAlias Sharp शार्प
Corner Color कोनों का रंग
Contiguous सन्निहित
Each Channel प्रत्येक चैनल
Color Mask… रंग मास्क…
Color Mask रंग मास्क
Remove Color Mask रंग मास्क हटाएँ
Apply Color Mask रंग मास्क लागू करें
Crop Marks क्रॉप मार्क
Crop To करने के लिए फसल
Cross क्रॉस
@@ -7665,6 +7669,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 एक इमेज सिलेक्ट करें, या आईडी पास करें
Can't render this file because it is too large.
+8
View File
@@ -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
@@ -5539,6 +5543,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)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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…
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
199
200
201
202
203
204
205
206
207
208
+8
View File
@@ -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
@@ -4839,6 +4843,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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -243,6 +243,10 @@ digits Malayalam マラヤーラム数字
Corner Color 角の色
Contiguous 隣接
Each Channel 各チャンネル
Color Mask… カラーマスク…
Color Mask カラーマスク
Remove Color Mask カラーマスクを削除
Apply Color Mask カラーマスクを適用
Edit Path パスを編集
Mask マスク
Rasterize… ラスタライズ…
@@ -4963,6 +4967,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}` は不明なツールです
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -171,6 +171,10 @@
Corner Color კუთხეების ფერი
Contiguous მომიჯნავე
Each Channel თითოეული არხი
Color Mask… ფერის ნიღაბი…
Color Mask ფერის ნიღაბი
Remove Color Mask ფერის ნიღბის მოშორება
Apply Color Mask ფერის ნიღბის გამოყენება
Mask ნიღაბი
Rasterize… რასტრირება…
Create Gradient Mesh… გრადიენტული ბადის შექმნა…
@@ -4454,6 +4458,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}
Can't render this file because it is too large.
+8
View File
@@ -236,6 +236,10 @@ digits Malayalam 말라얄람어
Corner Color 모퉁이 색상
Contiguous 인접
Each Channel 각 채널
Color Mask… 색상 마스크…
Color Mask 색상 마스크
Remove Color Mask 색상 마스크 제거
Apply Color Mask 색상 마스크 적용
Edit Path 패스 편집
Mask 마스크
Rasterize… 래스터화…
@@ -4890,6 +4894,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}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
@@ -4922,6 +4926,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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
@@ -4244,6 +4248,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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -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 Метки обрезки
@@ -4825,6 +4829,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}
Can't render this file because it is too large.
+8
View File
@@ -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
@@ -4683,6 +4687,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}
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -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
@@ -4536,6 +4540,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}
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -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 Мітки обрізання
@@ -4823,6 +4827,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}
Can't render this file because it is too large.
+8
View File
@@ -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
@@ -5202,6 +5206,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
Can't render this file because it contains an unexpected character in line 204 and column 5.
+8
View File
@@ -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 裁切标记
@@ -4655,6 +4659,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}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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 裁切標記
@@ -2424,6 +2428,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}`
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -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,
})
}
+56 -2
View File
@@ -10567,8 +10567,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
@@ -10615,6 +10616,59 @@ 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 when the mask hides pixels. A colorize color on such an image
follows the rule for grayscale images with transparency (a spot color, black or white), and Color Mask refuses a mask
that hides pixels of a grayscale image colorized with a process color. A mask that hides no pixels shows the image's
own pixels, so a grayscale image with such a mask takes a process color. Each image is checked on the pixels its new
mask shows, whatever other images in the document hold. Remove Background checks the image's own pixels, which it
clears.
- 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, every pixel would be hidden, or the image is grayscale and colorized with a process color
and the mask would hide some of its pixels), the mask keeps its settings and hides nothing.
- The command refuses, and changes nothing, when an object in `ids` is not an image, an image is CMYK, a linked image
shows its preview (embed or relink it first), an image would need more than the decode allowance, or the mask would
hide every pixel of an image.
- 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. Apply Color Mask
also refuses, and changes nothing, when a mask made again for new pixels would give a grayscale image colorized with a
process color an alpha channel. Both commands are
enabled only when a selected image has a mask. → `{changed, ids}`
```json
{"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
+4
View File
@@ -2075,6 +2075,10 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m
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 result has an alpha channel, and a process color can't colorize a grayscale image with one: [Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"). 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 (<https://community.adobe.com/announcements-651/ai-powered-image-editing-features-in-illustrator-1624061>); 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 and PDF/X-3 flatten 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 whose mask hides pixels takes only a spot, black or white colorize color; one whose mask hides no pixels takes a process color too. 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)
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Color Effects › Find Nearest Swatch… lists each colour of the selection (fills and strokes, as ticked; solid colours and gradient stops, inside groups and type) with the three swatches of the chosen library closest to it by CIEDE2000 difference, with that difference (`addon.color.nearestSwatch`, #4923). The library is the document's swatches, a built-in library, or a library or colour book the user saved or loaded (no colour book data ships with VectorCraft). Apply gives each colour its nearest swatch as one undo step (`addon.color.applyNearestSwatch`): global and spot swatches link, and a library's swatches the colours take are added to the document first, so a spot colour stays spot. Remaining: typing a colour to look up in the dialog (the command takes `color`), and applying a match other than the nearest. Tests: `cmd::addons::color::nearest::tests` (engine), `addons::color::nearest::tests::find_nearest_swatch_lists_the_matches_and_applies_the_nearest` (ui-egui).