From 0779da299b90054c64622da88fbb46cd062c4b1a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:53:57 -0800 Subject: [PATCH 01/22] Image Tools: Color Mask hides chosen colors of an image while its pixels stay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Plug-ins › Image Tools gains Color Mask… (`addon.image.colorMask`), Remove Color Mask and Apply Color Mask. Color Mask hides colors of the selected images without changing their pixels. It matches colors as Remove Background does: the edge color, the corner color or picked colors, with Contiguous, Match, Tolerance and Softness. The mask is kept with the image object and saved with the document: its settings, and a PNG of the image's pixels with the hidden colors made clear, at the source's depth with its color profile and resolution. It is a VectorCraft addition. - The canvas (clip groups and white-shape opacity masks included), printing and separations, PDF (soft masks of CMYK images included), SVG, EPS, the raster formats, the legacy .ai and .ai editing data, EMF/WMF, DXF and the flattener draw the masked pixels. A linked image stays linked, and an output that would refer to its file writes the masked pixels instead. - Copy and paste, placing a document and placed documents carry the masked pixels with the image, under the names they are given there. - The dialog opens on the selected image's mask settings, with a Preview. Without settings, the command returns them and changes nothing. - Setting, removing and applying a mask are each one undo step, and no step is added when nothing changes. Apply makes the masked pixels the image's own and embeds a linked image. A mask that hides nothing is only taken off, and the image keeps its pixels and its link. - When an image's own pixels are replaced (Update Link, Relink, Crop Image, a recolor, a linked file read again on open), its mask is made again for the new pixels, in every art tree and in opacity-mask art. Crop Image cuts the image's own pixels. When the new pixels can't be masked, the mask keeps its settings and hides nothing. - Objects in `ids` that are not images, CMYK images, linked images that show their preview, images past the decode allowance, masks that would hide every pixel, and grayscale images colorized with a process color are refused. The Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels. - An image is selected by a click anywhere in its frame, as before. Remove Background now also refuses a grayscale image colorized with a process color. Its result has an alpha channel, and a process color is refused on a grayscale image with transparency. Docs: docs/mcp.md (Image Tools add-on › Color Mask), docs/target-app-parity.md. --- crates/cad/src/scene.rs | 2 +- crates/doc/src/color_mask.rs | 175 +++++ crates/doc/src/lib.rs | 1 + crates/doc/src/mem.rs | 4 + crates/doc/src/node/extras.rs | 4 + crates/doc/src/node/wire.rs | 5 + crates/doc/src/placed_document.rs | 31 +- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/addons.rs | 2 +- crates/engine/src/cmd/addons/image.rs | 184 +++-- crates/engine/src/cmd/addons/image/mask.rs | 424 ++++++++++ .../engine/src/cmd/addons/image/mask/tests.rs | 728 ++++++++++++++++++ crates/engine/src/cmd/addons/image/tests.rs | 2 +- crates/engine/src/cmd/clipboard/resources.rs | 8 + crates/engine/src/cmd/flatten/analysis.rs | 5 +- crates/engine/src/cmd/flatten/options.rs | 3 +- crates/engine/src/cmd/links.rs | 2 + crates/engine/src/cmd/menucmds.rs | 5 + crates/engine/src/cmd/paint.rs | 6 +- crates/engine/src/cmd/place/adopt.rs | 6 + crates/engine/src/cmd/plugin_call/reviewed.rs | 3 + crates/engine/src/session/edit.rs | 2 + crates/eps/src/ai_editing/writer.rs | 2 +- crates/eps/src/legacy_ai.rs | 2 +- crates/eps/src/legacy_ai/writer.rs | 3 +- crates/eps/src/scene.rs | 2 + crates/metafile/src/scene.rs | 2 +- crates/pdf/src/export/images.rs | 3 + crates/pdf/src/export/nodes.rs | 7 +- crates/pdf/src/print/plates.rs | 5 + crates/pdf/src/tests/cmykimages.rs | 41 +- crates/pdf/src/tests/colorize.rs | 34 + crates/render/src/renderer/images.rs | 2 +- crates/render/src/renderer/nodes.rs | 9 +- crates/svg/src/export/nodes.rs | 2 + crates/svg/src/export/paint.rs | 6 +- crates/ui-egui/src/addons/image.rs | 28 +- crates/ui-egui/src/addons/image/tests.rs | 68 +- crates/ui-egui/src/dialogs/command.rs | 14 +- crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hi.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ka.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/menus/invoke.rs | 3 +- docs/mcp.md | 60 +- docs/target-app-parity.md | 6 +- 69 files changed, 1979 insertions(+), 120 deletions(-) create mode 100644 crates/doc/src/color_mask.rs create mode 100644 crates/engine/src/cmd/addons/image/mask.rs create mode 100644 crates/engine/src/cmd/addons/image/mask/tests.rs diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..de529aa325 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -660,7 +660,7 @@ impl<'a> Scene<'a> { self.shape(n, &bp, *rule); } NodeKind::Text(t) => self.text(n, t), - NodeKind::Image(im) => self.image(im), + NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im)), NodeKind::SymbolInstance { symbol, xf } => { let doc = self.doc; if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { diff --git a/crates/doc/src/color_mask.rs b/crates/doc/src/color_mask.rs new file mode 100644 index 0000000000..52673d526b --- /dev/null +++ b/crates/doc/src/color_mask.rs @@ -0,0 +1,175 @@ +//! Image Tools › Color Mask: colors of an image object that are hidden while its pixels stay as +//! they are. +//! +//! A masked image is an ordinary image ([`NodeKind::Image`]) with a [`ColorMask`] in its extras +//! ([`crate::node::NodeExtras::color_mask`]). The record holds the mask's settings and the key of +//! the image's pixels with the hidden colors made clear ([`ColorMask::key`]), made by the engine +//! from the pixels named by [`ColorMask::from`]. The image's own key keeps naming its own pixels, +//! so removing the mask gives them back. While the image's key is [`ColorMask::from`], it is drawn +//! and output with the masked pixels ([`Document::shown_image`]); when another key replaced it +//! (a link updated, the image cropped), it shows its own pixels until the engine makes the masked +//! pixels again. +//! +//! The concept is that of PDF's color key masking (ISO 32000-1, 8.9.6.4): samples in the chosen +//! colors are not painted and what is behind the image shows through. Which colors match, and how +//! near ones fade out, are VectorCraft's own rules. + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +use crate::{Document, ImageObject, Node, NodeKind}; + +/// Where a color mask's colors come from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MaskBackground { + /// The most common color along the image's edges. + #[default] + Edges, + /// The average of the image's corner pixels that are not clear. + Corners, + /// The colors in [`ColorMask::colors`]. + Colors, +} + +/// How near a color must be to a masked one. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MaskMatch { + /// The RGB distance, as a percentage of the largest one. + #[default] + Distance, + /// The largest difference on one of red, green and blue, as a percentage of a channel's range. + Channels, +} + +/// The colors an image object hides (see the module docs). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ColorMask { + /// Key into [`Document::images`]: the pixels of [`Self::from`] with the hidden colors made + /// clear. Named `key` in the file, so the file keeps those pixels with the document. + pub key: String, + /// The image's own key when the masked pixels were made. + pub from: String, + #[serde(default)] + pub background: MaskBackground, + /// The hidden colors (8-bit RGB) with [`MaskBackground::Colors`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub colors: Vec<[u8; 3]>, + /// Only the matching pixels that reach in from an edge are hidden. + #[serde(default)] + pub contiguous: bool, + #[serde(default, rename = "match")] + pub measure: MaskMatch, + /// How near a color must be to be hidden, 0 to 100 (see [`MaskMatch`]). + #[serde(default)] + pub tolerance: f64, + /// The band past the tolerance over which pixels fade from hidden to shown, 0 to 100. + #[serde(default)] + pub softness: f64, +} + +impl ColorMask { + /// Whether the masked pixels were made from the pixels of `im`. + pub fn made_for(&self, im: &ImageObject) -> bool { + im.pixels.is_none() && self.from == im.key + } +} + +impl Document { + /// Image `im` of node `n` as it is drawn and output. With a color mask whose masked pixels were + /// made from the image's own pixels and are in this document ([`ColorMask::made_for`]): a copy + /// that shows those pixels and links to no file, so an output that refers to a linked file + /// writes the masked pixels instead. Otherwise, and for a mask that hides nothing (its masked + /// pixels are the image's own), `im` itself. + pub fn shown_image<'a>(&self, n: &Node, im: &'a ImageObject) -> Cow<'a, ImageObject> { + match n.extras().color_mask.as_deref() { + Some(m) if m.key != im.key && m.made_for(im) && self.images.contains_key(&m.key) => { + Cow::Owned(ImageObject { key: m.key.clone(), link: None, ..im.clone() }) + } + _ => Cow::Borrowed(im), + } + } + + /// The color mask of image node `n`, if it has one. + pub fn color_mask_of(n: &Node) -> Option<&ColorMask> { + matches!(n.kind, NodeKind::Image(_)).then(|| n.extras().color_mask.as_deref()).flatten() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vectorcraft_geom::Affine; + + use super::*; + use crate::{ImageBlob, NodeId}; + + fn image(key: &str) -> ImageObject { + ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None } + } + + fn mask(key: &str, from: &str) -> ColorMask { + ColorMask { + key: key.into(), + from: from.into(), + background: MaskBackground::Colors, + colors: vec![[255, 255, 255]], + contiguous: false, + measure: MaskMatch::Distance, + tolerance: 10.0, + softness: 0.0, + } + } + + #[test] + fn a_masked_image_shows_its_masked_pixels_while_they_were_made_from_its_own() { + let mut d = Document::new(100.0, 100.0); + d.images.insert("own".into(), ImageBlob::png(vec![1])); + d.images.insert("masked".into(), ImageBlob::png(vec![2])); + let mut n = Node::new(NodeId(5), NodeKind::Image(Box::new(image("own")))); + let NodeKind::Image(im) = &n.kind else { unreachable_kind() }; + assert_eq!(d.shown_image(&n, im).key, "own", "no mask: its own pixels"); + n.extras_mut().color_mask = Some(Box::new(mask("masked", "own"))); + let NodeKind::Image(im) = &n.kind else { unreachable_kind() }; + assert_eq!(d.shown_image(&n, im).key, "masked"); + // Linked: the shown copy links to nothing, so outputs write the masked pixels. + let mut linked = (**im).clone(); + linked.link = Some(crate::LinkInfo::new("/a.png")); + assert_eq!(d.shown_image(&n, &linked).link, None); + // Made from other pixels: its own until they are made again. + assert_eq!(d.shown_image(&n, &image("other")).key, "other"); + // Masked pixels the document doesn't have: its own. + n.extras_mut().color_mask = Some(Box::new(mask("gone", "own"))); + let NodeKind::Image(im) = &n.kind else { unreachable_kind() }; + assert_eq!(d.shown_image(&n, im).key, "own"); + // Pixels an image holds itself are drawn as they are. + let own = ImageObject { pixels: Some(Arc::new(ImageBlob::png(vec![3]))), ..image("own") }; + n.extras_mut().color_mask = Some(Box::new(mask("masked", "own"))); + assert_eq!(d.shown_image(&n, &own).key, "own"); + // A mask that hides nothing (its masked pixels are the image's own): the image itself, so + // a linked image stays linked in outputs. + n.extras_mut().color_mask = Some(Box::new(mask("own", "own"))); + assert_eq!(d.shown_image(&n, &linked).link, linked.link); + } + + #[test] + fn a_mask_saves_and_reads_back_with_its_node() { + let mut n = Node::new(NodeId(5), NodeKind::Image(Box::new(image("own")))); + n.extras_mut().color_mask = Some(Box::new(mask("masked", "own"))); + let json = serde_json::to_value(&n).unwrap(); + assert_eq!(json["color_mask"]["key"], "masked", "the masked pixels' key is a `key` member: {json}"); + assert_eq!(json["color_mask"]["match"], "distance"); + let back: Node = serde_json::from_value(json).unwrap(); + assert_eq!(back, n); + // A node without one writes none. + let plain = Node::new(NodeId(6), NodeKind::Image(Box::new(image("own")))); + assert!(serde_json::to_value(&plain).unwrap().get("color_mask").is_none()); + } + + fn unreachable_kind() -> ! { + panic!("an image node") + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 41a684abde..7e7b8411c8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ pub mod blend; pub mod cancel; pub mod clipnest; pub mod cmyk; +pub mod color_mask; pub mod compound_shape; pub mod corners; pub mod depth; diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 12d233ba37..7ff6cdeea2 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -261,6 +261,10 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet) -> u b += ALLOC + size_of::(); b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective); b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize); + b += e + .color_mask + .as_deref() + .map_or(0, |m| ALLOC + size_of::() + m.key.capacity() + m.from.capacity() + m.colors.capacity() * 3); b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::() + path_bytes(a)); if e.intertwine_tiers.capacity() > 0 { diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 6865ff7e6c..7b130bf395 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -72,6 +72,9 @@ pub struct NodeExtras { /// Transform panel › Align to Pixel Grid: the path's or compound path's horizontal and vertical /// segments stay on the pixel grid when it is transformed. pub pixel_aligned: bool, + /// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are + /// ([`crate::color_mask`]). + pub color_mask: Option>, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -99,6 +102,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { symbol_overrides: Vec::new(), envelope_edit_margin: None, pixel_aligned: false, + color_mask: None, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 2722e7bee0..34d8e99c53 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -55,6 +55,7 @@ impl Serialize for Node { e.symbol_overrides.is_empty(), e.envelope_edit_margin.is_none(), is_default(&e.pixel_aligned), + e.color_mask.is_none(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -98,6 +99,7 @@ impl Serialize for Node { field!("symbol_overrides", &e.symbol_overrides); field!("envelope_edit_margin", &e.envelope_edit_margin); field!("pixel_aligned", &e.pixel_aligned); + field!("color_mask", &e.color_mask); st.end() } } @@ -241,6 +243,8 @@ pub(super) struct NodeWire { envelope_edit_margin: Option<[f32; 4]>, #[serde(default)] pixel_aligned: bool, + #[serde(default)] + color_mask: Option>, } impl From for Node { @@ -269,6 +273,7 @@ impl From for Node { symbol_overrides: w.symbol_overrides, envelope_edit_margin: w.envelope_edit_margin, pixel_aligned: w.pixel_aligned, + color_mask: w.color_mask, }; Node { id: w.id, diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 33eccb178a..86f7f6134c 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -517,7 +517,12 @@ fn rename(n: &mut Node, prefix: &str) { paint(&mut r.style.stroke); } } - NodeKind::Image(im) => im.key = format!("{prefix}{}", im.key), + NodeKind::Image(im) => { + im.key = format!("{prefix}{}", im.key); + if let Some(m) = n.extras.0.as_deref_mut().and_then(|e| e.color_mask.as_deref_mut()) { + (m.key, m.from) = (format!("{prefix}{}", m.key), format!("{prefix}{}", m.from)); + } + } NodeKind::PlacedDocument(p) => p.key = format!("{prefix}{}", p.key), NodeKind::SymbolInstance { symbol, .. } => *symbol = format!("{prefix}{symbol}"), _ => {} @@ -681,6 +686,30 @@ mod tests { Some((d, Node::new(NodeId(0), NodeKind::Group { children, clip: false }), Rect::new(0.0, 0.0, 40.0, 20.0))) } + /// A color-masked image in a placed document's art keeps its masked pixels under the prefixed + /// names its resources get. + #[test] + fn a_color_mask_takes_the_prefixed_image_names() { + let im = + ImageObject { key: "own".into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(NodeId(7), NodeKind::Image(Box::new(im))); + n.extras_mut().color_mask = Some(Box::new(crate::color_mask::ColorMask { + key: "masked".into(), + from: "own".into(), + background: Default::default(), + colors: vec![], + contiguous: true, + measure: Default::default(), + tolerance: 10.0, + softness: 10.0, + })); + rename(&mut n, "p:"); + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + let m = Document::color_mask_of(&n).unwrap(); + assert_eq!((im.key.as_str(), m.key.as_str(), m.from.as_str()), ("p:own", "p:masked", "p:own")); + assert!(m.made_for(im)); + } + #[test] fn round_trips_through_json() { let n = Node::new(NodeId(3), NodeKind::PlacedDocument(Box::new(placed("doc1-1")))); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 465025302f..c6f9941c89 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -766,7 +766,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; -pub(crate) use addons::DecodedImages; +pub(crate) use addons::{DecodedImages, refresh_color_masks}; pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 0815404835..66c67651a3 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -14,7 +14,7 @@ mod select; mod shapes; mod typetools; -pub(crate) use image::DecodedImages; +pub(crate) use image::{DecodedImages, refresh_color_masks}; use super::CommandSpec; diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index b2a9d74f83..d36b80cbb1 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -2,12 +2,16 @@ //! selected images transparent in their own pixels, as one undo step. The background is the most //! common color along an image's edges, the average of its four corners, or colors the caller //! picks. It is cleared where it reaches in from an edge, or wherever it is. The work is done here, -//! on the pixels; nothing is sent anywhere. It is a VectorCraft addition. Its submenu (Object › -//! Plug-ins › Image Tools) and dialog are the UI's. +//! on the pixels; nothing is sent anywhere. Color Mask ([`mask`]) hides colors matched the same +//! way while the image keeps its pixels. They are VectorCraft additions. Its submenu (Object › +//! Plug-ins › Image Tools) and dialogs are the UI's. +mod mask; #[cfg(test)] mod tests; +pub(crate) use mask::refresh as refresh_color_masks; + use std::collections::{HashMap, HashSet}; use std::io::Cursor; use std::sync::{Arc, Weak}; @@ -15,11 +19,12 @@ use std::sync::{Arc, Weak}; use image::{DynamicImage, ImageBuffer, LumaA, Rgba}; use serde_json::{Value, json}; use vectorcraft_color::Color; -use vectorcraft_doc::{ImageBlob, NodeId, NodeKind}; +use vectorcraft_doc::{ImageBlob, ImageObject, NodeId, NodeKind}; use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; +use super::super::paint::{check_colorized, check_colorized_kept}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -34,15 +39,17 @@ const MAX_COLORS: usize = 16; const WORK_BYTES: u64 = 10; pub fn specs() -> Vec { - vec![cmd!( + let mut v = vec![cmd!( "addon.image.removeBackground", "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background - )] + )]; + v.extend(mask::specs()); + v } /// Where the background's colors come from. @@ -78,50 +85,50 @@ pub(crate) struct Settings { } impl Settings { - /// The settings `p` gives, each checked. - fn read(p: &Value) -> Result { + /// The settings `p` gives to command `cmd`, each checked. + fn read(p: &Value, cmd: &str) -> Result { let percent = |key: &str| -> Result { - let v = number_or(p, key, 10.0, C)?; - if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(C, format!("`{key}` must be from 0 to 100"))) } + let v = number_or(p, key, 10.0, cmd)?; + if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from 0 to 100"))) } }; let (tolerance, softness) = (percent("tolerance")?, percent("softness")?); let colors = match p.get("colors") { None | Some(Value::Null) => None, - Some(v) => Some(colors(v)?), + Some(v) => Some(colors(v, cmd)?), }; - let background = match text_param(p, "background", C)? { + let background = match text_param(p, "background", cmd)? { None | Some("edges") => Background::Edges, Some("corners") => Background::Corners, - Some("colors") => Background::Colors(colors.ok_or_else(colors_error)?), - Some(_) => return Err(bad(C, "`background` must be edges, corners or colors")), + Some("colors") => Background::Colors(colors.ok_or_else(|| colors_error(cmd))?), + Some(_) => return Err(bad(cmd, "`background` must be edges, corners or colors")), }; - let measure = match text_param(p, "match", C)? { + let measure = match text_param(p, "match", cmd)? { None | Some("distance") => Match::Distance, Some("channels") => Match::Channels, - Some(_) => return Err(bad(C, "`match` must be distance or channels")), + Some(_) => return Err(bad(cmd, "`match` must be distance or channels")), }; - let contiguous = switch_or(p, "contiguous", true, C)?; + let contiguous = switch_or(p, "contiguous", true, cmd)?; Ok(Self { background, contiguous, measure, tolerance, softness }) } } -fn colors_error() -> crate::EngineError { - bad(C, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue") +fn colors_error(cmd: &str) -> crate::EngineError { + bad(cmd, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue") } -/// The colors `v` lists: an array of strings, or one string of them split by commas or spaces. -/// Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused. -fn colors(v: &Value) -> Result> { +/// The colors `v` lists to command `cmd`: an array of strings, or one string of them split by +/// commas or spaces. Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused. +fn colors(v: &Value, cmd: &str) -> Result> { // One past the most is enough to tell a list that is too long. let texts: Vec<&str> = match v { Value::String(s) => s.split([',', ' ', '\t', '\n']).filter(|t| !t.is_empty()).take(MAX_COLORS + 1).collect(), - Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::>().ok_or_else(colors_error)?, - _ => return Err(colors_error()), + Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::>().ok_or_else(|| colors_error(cmd))?, + _ => return Err(colors_error(cmd)), }; if texts.is_empty() || texts.len() > MAX_COLORS { - return Err(colors_error()); + return Err(colors_error(cmd)); } - texts.into_iter().map(|t| rgb8(t).ok_or_else(colors_error)).collect() + texts.into_iter().map(|t| rgb8(t).ok_or_else(|| colors_error(cmd))).collect() } /// The 8-bit RGB of the color `t` writes. @@ -134,7 +141,7 @@ fn rgb8(t: &str) -> Option<[u8; 3]> { } fn remove_background(s: &mut Session, p: &Value) -> Result { - let settings = Settings::read(p)?; + let settings = Settings::read(p, C)?; // A preview step keeps the pixels it decodes for the next step; other runs keep none. let mut kept = std::mem::take(&mut s.decoded_images); let previewing = s.previewing; @@ -144,6 +151,7 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { let ids: Vec = changes.iter().map(|(id, ..)| id.0).collect(); let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum(); if !changes.is_empty() { + let colorized_ok = check_colorized(&s.doc()?.doc).is_ok(); s.edit("Remove Background", |d, _| { for (id, out, xf) in changes { let key = out.blob.content_key(); @@ -154,7 +162,8 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { im.link = None; } } - Ok(()) + // A grayscale image colorized with a process color can't take an alpha channel. + check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) })?; } Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear })) @@ -164,35 +173,7 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { /// cleared as `settings` say and the transform that keeps its place and size. Decoded pixels are /// taken from and kept in `kept` when it is given. fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { - let st = s.doc()?; - let ids: Vec = if p.get("ids").is_some_and(|v| !v.is_null()) { - // Each image once, however often `ids` names it. - let mut given = HashSet::new(); - let ids: Vec = checked_ids_param(s, p, "ids", C)?.into_iter().filter(|id| given.insert(*id)).collect(); - if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { - return Err(bad(C, format!("object {} is not an image", id.0))); - } - ids - } else { - st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect() - }; - if ids.is_empty() { - return Err(bad(C, "select an image")); - } - // Every image is checked before any is decoded. - let mut images = vec![]; - for id in ids { - let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; - let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; - // The preview stands in for a file that hasn't been read, or that is kept at its preview. - if im.link.is_some() && blob.is_proxy() { - return Err(bad(C, "the linked image shows its preview: embed or relink it first")); - } - if blob.is_cmyk() { - return Err(bad(C, "a CMYK image has no transparency to set")); - } - images.push((id, im, blob)); - } + let images = images(s, p, C)?; // Each image's pixels once, however many objects show them. let mut cleared: HashMap<&str, Option> = HashMap::new(); let mut changes = vec![]; @@ -200,7 +181,7 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De let out = match cleared.get(im.key.as_str()) { Some(out) => out.clone(), None => { - let out = clear(blob, settings, kept.as_deref_mut())?; + let out = clear(blob, settings, kept.as_deref_mut(), C)?; cleared.insert(&im.key, out.clone()); out } @@ -218,6 +199,58 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De Ok(changes) } +/// The image objects command `cmd` acts on (`ids`, each once, or the selected images), each with +/// its pixels, all checked before any is decoded: an object that is not an image, missing pixels, +/// a linked image that shows its preview and a CMYK image are errors, as is no image at all. +fn images<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result> { + let st = s.doc()?; + let mut images = vec![]; + for id in targets(s, p, cmd)? { + let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(cmd, "the image's pixels are missing"))?; + // The preview stands in for a file that hasn't been read, or that is kept at its preview. + if im.link.is_some() && blob.is_proxy() { + return Err(bad(cmd, "the linked image shows its preview: embed or relink it first")); + } + if blob.is_cmyk() { + return Err(cmyk_refusal(cmd)); + } + images.push((id, &**im, blob)); + } + Ok(images) +} + +/// The image objects command `cmd` acts on: `ids`, each once, or the selected images. An object in +/// `ids` that is not an image is an error, as is no image at all. +fn targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let st = s.doc()?; + let ids: Vec = if p.get("ids").is_some_and(|v| !v.is_null()) { + // Each image once, however often `ids` names it. + let mut given = HashSet::new(); + let ids: Vec = checked_ids_param(s, p, "ids", cmd)?.into_iter().filter(|id| given.insert(*id)).collect(); + if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { + return Err(bad(cmd, format!("object {} is not an image", id.0))); + } + ids + } else { + st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect() + }; + if ids.is_empty() { + return Err(bad(cmd, "select an image")); + } + Ok(ids) +} + +/// Whether `cmd` is one of Color Mask's commands, which hide pixels instead of clearing them. +fn masks(cmd: &str) -> bool { + matches!(cmd, mask::COLOR_MASK | mask::REMOVE_COLOR_MASK | mask::APPLY_COLOR_MASK) +} + +/// Command `cmd`'s refusal of a CMYK image. +fn cmyk_refusal(cmd: &str) -> crate::EngineError { + if masks(cmd) { bad(cmd, "a CMYK image can't take a color mask") } else { bad(cmd, "a CMYK image has no transparency to set") } +} + /// An image with its background cleared: its PNG, its size in pixels, and how many of its pixels /// were made fully clear. #[derive(Clone, Debug)] @@ -345,16 +378,16 @@ impl Pixels { /// `blob` with its background cleared as `settings` say, or `None` when no pixel changes. A CMYK /// image, one that can't be decoded or is too large to change, and a result with no pixel left are -/// errors. The PNG keeps the source's ICC profile and resolution. The decoded pixels are taken -/// from and kept in `kept` when it is given. -pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { +/// errors of command `cmd`. The PNG keeps the source's ICC profile and resolution. The decoded +/// pixels are taken from and kept in `kept` when it is given. +pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>, cmd: &str) -> Result> { if blob.is_cmyk() { - return Err(bad(C, "a CMYK image has no transparency to set")); + return Err(cmyk_refusal(cmd)); } let found = kept.as_deref_mut().and_then(|k| k.get(&blob.bytes)); let (source, gray, icc) = match found { Some(found) => found, - None => decode(blob, kept)?, + None => decode(blob, kept, cmd)?, }; // The pixels kept for the next run stay as they are; ones not kept are worked on in place. let mut px = Arc::try_unwrap(source).unwrap_or_else(|kept| Pixels::clone(&kept)); @@ -366,7 +399,11 @@ pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut Pixels::Sixteen(img) => img.pixels().any(|p| p.0[3] > 0), }; if !left { - return Err(bad(C, "every pixel matches the background: no image would be left")); + return Err(if masks(cmd) { + bad(cmd, "the mask would hide every pixel") + } else { + bad(cmd, "every pixel matches the background: no image would be left") + }); } let out = match (px, gray) { (Pixels::Eight(img), false) => DynamicImage::ImageRgba8(img), @@ -382,9 +419,9 @@ pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut let mut encoder = image::codecs::png::PngEncoder::new(&mut png); if let Some(icc) = icc { use image::ImageEncoder as _; - encoder.set_icc_profile(icc).map_err(|e| bad(C, e.to_string()))?; + encoder.set_icc_profile(icc).map_err(|e| bad(cmd, e.to_string()))?; } - out.write_with_encoder(encoder).map_err(|e| bad(C, e.to_string()))?; + out.write_with_encoder(encoder).map_err(|e| bad(cmd, e.to_string()))?; // The resolution the file declares, which sizes the image at 100% when it is replaced or relinked. if let Some(r) = ppi::resolution(&blob.bytes) { png = ppi::with_png_resolution(&png, r); @@ -394,11 +431,12 @@ pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut /// `blob`'s pixels as straight RGBA, whether they were gray, and its ICC profile. An image that /// can't be decoded or is too large to change is an error. The pixels are kept in `kept`, when it -/// is given, if a copy of them fits the decode allowance beside the work. -fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc, bool, Option>)> { - let reader = - || image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(C, "the image's pixels can't be read")); - let mut decoder = reader()?.into_decoder().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; +/// is given, if a copy of them fits the decode allowance beside the work. Errors are command `cmd`'s. +fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>, cmd: &str) -> Result<(Arc, bool, Option>)> { + let reader = || { + image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(cmd, "the image's pixels can't be read")) + }; + let mut decoder = reader()?.into_decoder().map_err(|e| bad(cmd, format!("the image's pixels can't be read: {e}")))?; // The ICC profile the colors are in goes with them into the PNG. let (w, h, kind, icc) = { use image::ImageDecoder as _; @@ -411,13 +449,13 @@ fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc kind.channel_count(); let per_pixel = u64::from(kind.bytes_per_pixel()) + 2 * if deep { 8 } else { 4 } + WORK_BYTES; if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(per_pixel)).is_none_or(|n| n > MAX_RASTER_ALLOC) { - return Err(bad(C, format!("an image of {w} × {h} pixels is too large to change"))); + return Err(bad(cmd, format!("an image of {w} × {h} pixels is too large to change"))); } let mut reader = reader()?; let mut limits = image::Limits::default(); limits.max_alloc = Some(MAX_RASTER_ALLOC); reader.limits(limits); - let img = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + let img = reader.decode().map_err(|e| bad(cmd, format!("the image's pixels can't be read: {e}")))?; #[cfg(test)] DECODES.with(|c| c.set(c.get() + 1)); let gray = matches!(img.color(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16); diff --git a/crates/engine/src/cmd/addons/image/mask.rs b/crates/engine/src/cmd/addons/image/mask.rs new file mode 100644 index 0000000000..d6dc451f4f --- /dev/null +++ b/crates/engine/src/cmd/addons/image/mask.rs @@ -0,0 +1,424 @@ +//! Image Tools › Color Mask (`addon.image.colorMask`, `addon.image.removeColorMask`, +//! `addon.image.applyColorMask`): colors of the selected images are hidden while their pixels +//! stay as they are. The colors are matched as Remove Background matches them ([`super::clear`]); +//! the image keeps its own pixels, and the mask's settings and the masked pixels are kept with it +//! ([`vectorcraft_doc::color_mask`]), so the mask can be changed, removed, or applied to the +//! pixels later. When an image's own pixels are replaced (a link updated or relinked, the image +//! cropped or recolored), its mask is applied to the new pixels ([`refresh`]). + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::color_mask::{ColorMask, MaskBackground, MaskMatch}; +use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::super::super::paint::{check_colorized, check_colorized_kept}; +use super::super::super::{CommandSpec, Result, Session, bad, cmd}; +use super::{Background, DecodedImages, MAX_COLORS, Match, Settings, clear, images, targets}; + +/// Color Mask's command. +pub const COLOR_MASK: &str = "addon.image.colorMask"; +/// Remove Color Mask's command. +pub const REMOVE_COLOR_MASK: &str = "addon.image.removeColorMask"; +/// Apply Color Mask's command. +pub const APPLY_COLOR_MASK: &str = "addon.image.applyColorMask"; + +/// The params that set a mask; with none of them, Color Mask only reports. +const SETTINGS: [&str; 6] = ["background", "colors", "contiguous", "match", "tolerance", "softness"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.image.colorMask", + "Color Mask…", + ["Object", "Plug-ins", "Image Tools"], + None, + "{background?: \"edges\" (default: the most common color along the image's edges) | \"corners\" (the average of the image's four corner pixels that are not clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 hex colors or black, white, red, green and blue, as addon.image.removeBackground takes them; needed with background: colors), contiguous?: bool (true: only matching pixels that reach in from an edge are hidden; false: wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a hidden color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (% as addon.image.removeBackground measures it; a pixel this near a hidden color is hidden; 10), softness?: 0..100 (% past the tolerance over which pixels fade from hidden to shown; 10), ids?: [id…] (default: the selected images)} hide colors of each image without changing its pixels, as one undo step: the image keeps its own pixels, and the mask (these settings, and the image's pixels with the hidden colors made clear, made as Remove Background makes them) is kept with the image and saved with the document; the canvas, printing and separations, PDF, SVG, EPS, raster formats, DXF, EMF, WMF, the legacy .ai and the .ai editing data, copy and paste, and placing a document show the masked pixels, and a click on any part of the image's frame selects it (the Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels); setting the mask again replaces it, and an image whose mask does not change is left as it is (no undo step when none changes); when the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, a linked file read again on open), the mask is made again for the new pixels; an object in `ids` that is not an image, a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a mask that would hide every pixel, and a grayscale image colorized with a process color (masked gray pixels have transparency, which only a spot color, black or white colorizes) are refused. With none of background, colors, contiguous, match, tolerance and softness given, nothing changes and the mask settings of the first image (or the defaults when it has no mask) are returned → {changed: the images changed, ids, hidden: the pixels made fully clear in them} or {background, colors, contiguous, match, tolerance, softness, masked}", + super::has_image, + color_mask + ), + cmd!( + "addon.image.removeColorMask", + "Remove Color Mask", + ["Object", "Plug-ins", "Image Tools"], + None, + "{ids?: [id…] (default: the selected images)} take the color mask off each image, as one undo step: the image shows its own pixels again; images without a mask are left as they are (no undo step when none has one), and an object in `ids` that is not an image is refused → {changed, ids}", + has_color_mask, + remove_color_mask + ), + cmd!( + "addon.image.applyColorMask", + "Apply Color Mask", + ["Object", "Plug-ins", "Image Tools"], + None, + "{ids?: [id…] (default: the selected images)} make each image's color mask part of its pixels, as one undo step: the masked pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is taken off; a mask that hides nothing is only taken off (the image keeps its pixels and its link); images without a mask are left as they are (no undo step when none has one), and an object in `ids` that is not an image is refused → {changed, ids}", + has_color_mask, + apply_color_mask + ), + ] +} + +/// Enabled when a selected image has a color mask. +fn has_color_mask(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.selection.objects.iter().any(|id| st.doc.node(*id).and_then(Document::color_mask_of).is_some()) { + Ok(()) + } else { + Err("select an image with a color mask".into()) + } +} + +impl Settings { + /// The settings a mask was made with, `None` when a file gave ones out of range. + fn of(m: &ColorMask) -> Option { + let percent = |v: f64| v.is_finite() && (0.0..=100.0).contains(&v); + if !percent(m.tolerance) || !percent(m.softness) { + return None; + } + let background = match m.background { + MaskBackground::Edges => Background::Edges, + MaskBackground::Corners => Background::Corners, + MaskBackground::Colors if (1..=MAX_COLORS).contains(&m.colors.len()) => Background::Colors(m.colors.clone()), + MaskBackground::Colors => return None, + }; + let measure = match m.measure { + MaskMatch::Distance => Match::Distance, + MaskMatch::Channels => Match::Channels, + }; + Some(Self { background, contiguous: m.contiguous, measure, tolerance: m.tolerance, softness: m.softness }) + } + + /// A mask with these settings whose masked pixels are `key`, made from the pixels `from`. + fn mask(&self, key: String, from: String) -> ColorMask { + let (background, colors) = match &self.background { + Background::Edges => (MaskBackground::Edges, vec![]), + Background::Corners => (MaskBackground::Corners, vec![]), + Background::Colors(c) => (MaskBackground::Colors, c.clone()), + }; + let measure = match self.measure { + Match::Distance => MaskMatch::Distance, + Match::Channels => MaskMatch::Channels, + }; + ColorMask { key, from, background, colors, contiguous: self.contiguous, measure, tolerance: self.tolerance, softness: self.softness } + } +} + +/// The masked pixels of `blob` (`None`: no pixel is hidden, the image's own pixels show) as +/// `settings` say, with errors of command `cmd`. +fn masked(blob: &ImageBlob, settings: &Settings, kept: Option<&mut DecodedImages>, cmd: &str) -> Result> { + Ok(clear(blob, settings, kept, cmd)?.map(|out| (out.blob.content_key(), out.blob, out.cleared))) +} + +fn color_mask(s: &mut Session, p: &Value) -> Result { + if !SETTINGS.iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { + return report(s, p); + } + let settings = Settings::read(p, COLOR_MASK)?; + // A preview step keeps the pixels it decodes for the next step; other runs keep none. + let mut kept = std::mem::take(&mut s.decoded_images); + let previewing = s.previewing; + let found = mask_changes(s, p, &settings, previewing.then_some(&mut kept)); + s.decoded_images = kept; + let changes = found?; + let ids: Vec = changes.iter().map(|c| c.0.0).collect(); + let hidden: u64 = changes.iter().map(|c| c.3).sum(); + if !changes.is_empty() { + let colorized_ok = check_colorized(&s.doc()?.doc).is_ok(); + s.edit("Color Mask", |d, _| { + for (id, mask, blob, _) in changes { + if let Some(blob) = blob { + d.images.entry(mask.key.clone()).or_insert(blob); + } + if let Some(n) = d.node_mut(id) { + n.extras_mut().color_mask = Some(Box::new(mask)); + } + } + // Masked pixels have an alpha channel, which a grayscale image colorized with a process + // color can't take. + check_colorized_kept(colorized_ok, d).map_err(|e| bad(COLOR_MASK, e)) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids, "hidden": hidden })) +} + +/// The images `p` names whose mask changes, each with its new mask, the masked pixels to add (when +/// the document doesn't have them) and how many pixels they hide. +type MaskChange = (NodeId, ColorMask, Option, u64); + +fn mask_changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + let st = s.doc()?; + // Each image's pixels once, however many objects show them. + let mut made: HashMap<&str, (String, Option, u64)> = HashMap::new(); + let mut out = vec![]; + for (id, im, blob) in images(s, p, COLOR_MASK)? { + let (key, pixels, hidden) = match made.get(im.key.as_str()) { + Some(m) => m.clone(), + None => { + let m = match masked(blob, settings, kept.as_deref_mut(), COLOR_MASK)? { + Some((key, blob, hidden)) => (key, Some(blob), hidden), + None => (im.key.clone(), None, 0), + }; + made.insert(&im.key, m.clone()); + m + } + }; + let mask = settings.mask(key, im.key.clone()); + if st.doc.node(id).and_then(Document::color_mask_of) == Some(&mask) { + continue; + } + let pixels = pixels.filter(|_| !st.doc.images.contains_key(&mask.key)); + out.push((id, mask, pixels, hidden)); + } + Ok(out) +} + +/// Color Mask without settings: the first image's mask settings, or the defaults. It reads them +/// for images a change would refuse too (CMYK, a linked image at its preview), so the dialog opens. +fn report(s: &Session, p: &Value) -> Result { + let st = s.doc()?; + let first = targets(s, p, COLOR_MASK)?.first().and_then(|id| st.doc.node(*id)).and_then(Document::color_mask_of).cloned(); + let m = first.as_ref(); + let hex = |c: &[u8; 3]| format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]); + let colors = + m.filter(|m| !m.colors.is_empty()).map_or_else(|| "#ffffff".to_string(), |m| m.colors.iter().map(hex).collect::>().join(", ")); + let name = |v: Value| v.as_str().map(str::to_string).unwrap_or_default(); + Ok(json!({ + "background": name(serde_json::to_value(m.map_or(MaskBackground::Edges, |m| m.background)).unwrap_or_default()), + "colors": colors, + "contiguous": m.is_none_or(|m| m.contiguous), + "match": name(serde_json::to_value(m.map_or(MaskMatch::Distance, |m| m.measure)).unwrap_or_default()), + "tolerance": m.map_or(10.0, |m| m.tolerance), + "softness": m.map_or(10.0, |m| m.softness), + "masked": m.is_some(), + })) +} + +/// The image objects `p` names ([`targets`]: `ids`, or the selected images) that have a color mask. +fn masked_images(s: &Session, p: &Value, cmd: &str) -> Result> { + let st = s.doc()?; + Ok(targets(s, p, cmd)?.into_iter().filter(|id| st.doc.node(*id).and_then(Document::color_mask_of).is_some()).collect()) +} + +fn remove_color_mask(s: &mut Session, p: &Value) -> Result { + let ids = masked_images(s, p, REMOVE_COLOR_MASK)?; + if !ids.is_empty() { + s.edit("Remove Color Mask", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.extras_mut().color_mask = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids.iter().map(|id| id.0).collect::>() })) +} + +fn apply_color_mask(s: &mut Session, p: &Value) -> Result { + let ids = masked_images(s, p, APPLY_COLOR_MASK)?; + // Each image's masked pixels, made again first when its own pixels changed since. A mask that + // hides nothing (its masked pixels are the image's own) is only taken off: the image keeps its + // pixels and its link. + let mut made = vec![]; + { + let st = s.doc()?; + for id in &ids { + let Some(n) = st.doc.node(*id) else { continue }; + let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) else { continue }; + let mask = match fresh(&st.doc, n, APPLY_COLOR_MASK) { + Some(Ok(fresh)) => fresh, + Some(Err(e)) => return Err(e), + None => (m.clone(), None), + }; + if mask.0.key == im.key { + made.push((*id, None)); + continue; + } + let size = mask_size(st.doc.images.get(&mask.0.key).or(mask.1.as_ref()), im.width, im.height); + made.push((*id, Some((mask, size)))); + } + } + if !made.is_empty() { + s.edit("Apply Color Mask", |d, _| { + for (id, applied) in made { + let Some(n) = d.node_mut(id) else { continue }; + n.extras_mut().color_mask = None; + let Some(((mask, blob), (w, h))) = applied else { continue }; + if let Some(blob) = blob { + d.images.entry(mask.key.clone()).or_insert(blob); + } + let Some(n) = d.node_mut(id) else { continue }; + if let NodeKind::Image(im) = &mut n.kind { + // Masked pixels of another size than the object's keep its place and size. + if (w, h) != (im.width, im.height) { + im.xf *= Affine::scale_non_uniform(f64::from(im.width) / f64::from(w), f64::from(im.height) / f64::from(h)); + (im.width, im.height) = (w, h); + } + im.key = mask.key; + im.link = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids.iter().map(|id| id.0).collect::>() })) +} + +/// The size in pixels of masked pixels `blob` (from its header), or the object's when it can't be +/// read. +fn mask_size(blob: Option<&ImageBlob>, width: u32, height: u32) -> (u32, u32) { + blob.and_then(|b| image::ImageReader::new(std::io::Cursor::new(b.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()) + .filter(|(w, h)| *w > 0 && *h > 0) + .unwrap_or((width, height)) +} + +/// The mask of image node `n` made again for the image's own pixels, with the masked pixels when +/// `doc` doesn't have them: `None` when the mask is up to date (or `n` has none), an error when the +/// pixels can't be masked (missing, CMYK, a linked preview, too large, every pixel hidden, or +/// settings a file gave out of range). +fn fresh(doc: &Document, n: &Node, cmd: &str) -> Option)>> { + let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) else { return None }; + if !stale_mask(doc, n) { + return None; + } + let made = || -> Result<(ColorMask, Option)> { + let settings = Settings::of(m).ok_or_else(|| bad(cmd, "the color mask's settings are out of range"))?; + let blob = doc.images.get(&im.key).ok_or_else(|| bad(cmd, "the image's pixels are missing"))?; + if im.link.is_some() && blob.is_proxy() { + return Err(bad(cmd, "the linked image shows its preview: embed or relink it first")); + } + Ok(match masked(blob, &settings, None, cmd)? { + Some((key, blob, _)) => { + let blob = (!doc.images.contains_key(&key)).then_some(blob); + (settings.mask(key, im.key.clone()), blob) + } + None => (settings.mask(im.key.clone(), im.key.clone()), None), + }) + }; + Some(made()) +} + +/// After an edit from `before` (`None`: everything is new, as after a document's links are read): +/// the color masks of images in `doc`'s art ([`Document::art_roots`]: layers, symbols, graph +/// designs, patterns and the original of the pattern being edited, opacity mask art included) +/// whose own pixels were replaced are made again for the new pixels ([`Refresh::made`]). Only the +/// art that changed since `before` is looked at. +pub(crate) fn refresh(before: Option<&Document>, doc: &mut Document) { + let old: Vec<&Arc> = before.map(|b| b.art_roots().collect()).unwrap_or_default(); + let mut pass = Refresh { doc: &*doc, made: HashMap::new(), blobs: vec![] }; + let mut fixed = vec![]; + for (i, root) in doc.art_roots().enumerate() { + let counterpart = old.get(i).copied().filter(|o| o.id == root.id); + if stale_in(doc, root, counterpart) { + let mut root = root.clone(); + pass.fix(Arc::make_mut(&mut root)); + fixed.push((i, root)); + } + } + let blobs = pass.blobs; + if fixed.is_empty() { + return; + } + for (key, blob) in blobs { + doc.images.entry(key).or_insert(blob); + } + let mut fixed = fixed.into_iter().peekable(); + for (i, root) in doc.art_roots_mut().enumerate() { + if fixed.peek().is_some_and(|(at, _)| *at == i) + && let Some((_, new)) = fixed.next() + { + *root = new; + } + } +} + +/// Whether `n`, or anything in it (opacity-mask art included), is an image whose mask is out of +/// date ([`stale_mask`]). What is the same as in `old`, `n`'s counterpart before an edit, is not +/// looked at. +fn stale_in(doc: &Document, n: &Arc, old: Option<&Arc>) -> bool { + if old.is_some_and(|o| Arc::ptr_eq(o, n)) { + return false; + } + if stale_mask(doc, n) { + return true; + } + let old_mask = old.and_then(|o| o.extras().mask.as_ref()).map(|m| &m.art); + if n.extras().mask.as_ref().is_some_and(|m| stale_in(doc, &m.art, old_mask)) { + return true; + } + let Some(children) = n.children() else { return false }; + let before = old.and_then(|o| o.children()).map_or(&[][..], Vec::as_slice); + let mut by_id: Option>> = None; + children.iter().enumerate().any(|(i, c)| { + let b = match before.get(i) { + Some(b) if b.id == c.id => Some(b), + _ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&c.id).copied(), + }; + stale_in(doc, c, b) + }) +} + +/// Whether `n` is an image whose color mask was not made from its own pixels (or whose masked +/// pixels `doc` doesn't have). A mask that hides nothing (its masked pixels are the image's own) +/// is up to date while the image's key stays the same, even when its pixels are missing. +fn stale_mask(doc: &Document, n: &Node) -> bool { + matches!((&n.kind, Document::color_mask_of(n)), (NodeKind::Image(im), Some(m)) + if im.pixels.is_none() && !(m.made_for(im) && (m.key == im.key || doc.images.contains_key(&m.key)))) +} + +/// One [`refresh`] pass: the masks made so far, by the image's pixels and the mask's settings (an +/// image shown by many objects is decoded once), and the masked pixels to add to the document. +struct Refresh<'a> { + doc: &'a Document, + made: HashMap<(String, String), ColorMask>, + blobs: Vec<(String, ImageBlob)>, +} + +impl Refresh<'_> { + /// Make the masks out of date in `n` (a copy of the document's art) again. + fn fix(&mut self, n: &mut Node) { + if let Some(mask) = self.made(n) { + n.extras_mut().color_mask = Some(Box::new(mask)); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && stale_in(self.doc, &m.art, None) + { + self.fix(Arc::make_mut(&mut m.art)); + } + for c in n.children_mut().into_iter().flatten() { + if stale_in(self.doc, c, None) { + self.fix(Arc::make_mut(c)); + } + } + } + + /// The mask of image node `n` made again for the image's own pixels ([`fresh`]), `None` when + /// it is up to date. A mask that can't be made for them (every pixel hidden, CMYK pixels, a + /// linked preview, too large) keeps its settings and hides nothing: the image shows its own + /// pixels, and setting the mask again reports why. + fn made(&mut self, n: &Node) -> Option { + let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) else { return None }; + if !stale_mask(self.doc, n) { + return None; + } + let settings = ColorMask { key: String::new(), from: String::new(), ..m.clone() }; + let at = (im.key.clone(), format!("{settings:?}")); + if let Some(made) = self.made.get(&at) { + return Some(made.clone()); + } + let mask = match fresh(self.doc, n, COLOR_MASK)? { + Ok((mask, blob)) => { + self.blobs.extend(blob.map(|b| (mask.key.clone(), b))); + mask + } + Err(_) => ColorMask { key: im.key.clone(), from: im.key.clone(), ..m.clone() }, + }; + self.made.insert(at, mask.clone()); + Some(mask) + } +} diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs new file mode 100644 index 0000000000..1366a3dcf5 --- /dev/null +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -0,0 +1,728 @@ +use std::io::Cursor; + +use image::DynamicImage; +use serde_json::{Value, json}; +use vectorcraft_doc::color_mask::{ColorMask, MaskBackground}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, NodeId, NodeKind}; +use vectorcraft_geom::Rect; + +use super::{APPLY_COLOR_MASK, COLOR_MASK, REMOVE_COLOR_MASK}; +use crate::Session; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [200, 0, 0, 255]; + +fn png(img: DynamicImage) -> Vec { + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// 12 × 10 pixels: white, with a red block from (3, 3) to (9, 7) that holds one white pixel at +/// (6, 5). +fn framed() -> Vec { + png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(12, 10, |x, y| { + let block = (3..9).contains(&x) && (3..7).contains(&y); + image::Rgba(if block && (x, y) != (6, 5) { RED } else { WHITE }) + }))) +} + +/// Hide white wherever it is, exactly. +fn hide_white() -> Value { + json!({"background": "colors", "colors": "#ffffff", "contiguous": false, "tolerance": 0, "softness": 0}) +} + +/// A session with `bytes` opened as a document, its image selected. +fn open(bytes: &[u8]) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + (s, id) +} + +fn doc(s: &Session) -> &Document { + &s.doc().unwrap().doc +} + +fn image_of(d: &Document, id: NodeId) -> ImageObject { + let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!("an image") }; + (**im).clone() +} + +fn mask_of(d: &Document, id: NodeId) -> Option { + Document::color_mask_of(d.node(id).unwrap()).cloned() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The straight RGBA of pixel (`x`, `y`) of image `id` as `d` draws it, on nothing. +fn drawn(d: &Document, id: NodeId, x: u32, y: u32) -> [u8; 4] { + let im = image_of(d, id); + let at = im.xf * vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + let r = vectorcraft_render::Renderer::new().render_region(d, Rect::new(at.x - 0.5, at.y - 0.5, at.x + 0.5, at.y + 0.5), 1.0, false); + let px = r.to_straight(); + [px[0], px[1], px[2], px[3]] +} + +#[test] +fn the_mask_hides_the_chosen_colors_while_the_pixels_stay() { + let (mut s, id) = open(&framed()); + let before = (image_of(doc(&s), id), doc(&s).images[&image_of(doc(&s), id).key].bytes.clone(), undo_len(&s)); + assert_eq!(drawn(doc(&s), id, 0, 0), WHITE); + let r = s.execute(COLOR_MASK, &hide_white()).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [id.0], "hidden": 12 * 10 - 6 * 4 + 1})); + let d = doc(&s); + assert_eq!(image_of(d, id), before.0, "the image object is as it was"); + assert_eq!(d.images[&before.0.key].bytes, before.1, "its pixels are as they were"); + let m = mask_of(d, id).unwrap(); + assert_eq!((m.from.as_str(), m.background, m.contiguous), (before.0.key.as_str(), MaskBackground::Colors, false)); + assert_eq!(drawn(d, id, 0, 0)[3], 0, "white is hidden"); + assert_eq!(drawn(d, id, 6, 5)[3], 0, "inside the block too"); + assert_eq!(drawn(d, id, 4, 4), RED, "the red shows"); + assert_eq!(undo_len(&s), before.2 + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(mask_of(doc(&s), id), None); + assert_eq!(drawn(doc(&s), id, 0, 0), WHITE); +} + +#[test] +fn the_mask_is_edited_reported_and_removed() { + let (mut s, id) = open(&framed()); + let r = s.execute(COLOR_MASK, &json!({})).unwrap(); + assert_eq!( + r, + json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10.0, "softness": 10.0, "masked": false}) + ); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let steps = undo_len(&s); + // The same settings again: nothing changes. + assert_eq!(s.execute(COLOR_MASK, &hide_white()).unwrap()["changed"], 0); + assert_eq!(undo_len(&s), steps, "no undo step"); + // Contiguous: the white inside the block shows again. + let r = s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#ffffff", "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!((drawn(doc(&s), id, 0, 0)[3], drawn(doc(&s), id, 6, 5)), (0, WHITE)); + let r = s.execute(COLOR_MASK, &json!({})).unwrap(); + assert_eq!( + r, + json!({"background": "colors", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 0.0, "softness": 0.0, "masked": true}) + ); + let steps = undo_len(&s); + assert_eq!(s.execute(REMOVE_COLOR_MASK, &json!({})).unwrap(), json!({"changed": 1, "ids": [id.0]})); + assert_eq!(undo_len(&s), steps + 1); + assert_eq!((mask_of(doc(&s), id), drawn(doc(&s), id, 0, 0)), (None, WHITE)); + // Nothing left to remove: the command is not enabled, and adds no undo step. + let e = s.execute(REMOVE_COLOR_MASK, &json!({})).unwrap_err(); + assert!(e.to_string().contains("select an image with a color mask"), "{e}"); + assert_eq!(undo_len(&s), steps + 1); +} + +#[test] +fn applying_the_mask_makes_it_part_of_the_pixels() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let m = mask_of(doc(&s), id).unwrap(); + let steps = undo_len(&s); + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({})).unwrap(), json!({"changed": 1, "ids": [id.0]})); + let d = doc(&s); + assert_eq!((image_of(d, id).key, mask_of(d, id)), (m.key.clone(), None)); + let px = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(4, 4).0), (0, RED)); + assert_eq!(drawn(d, id, 0, 0)[3], 0); + assert_eq!(undo_len(&s), steps + 1); + assert!(s.execute(APPLY_COLOR_MASK, &json!({})).is_err(), "no mask left"); +} + +/// The bytes of the first `data:image/png;base64,` image in `svg`. +fn svg_image(svg: &str) -> DynamicImage { + let at = svg.find("data:image/png;base64,").expect("an embedded PNG") + "data:image/png;base64,".len(); + let end = at + svg[at..].find('"').unwrap(); + image::load_from_memory(&vectorcraft_format::base64_decode(&svg[at..end]).unwrap()).unwrap() +} + +#[test] +fn outputs_write_the_masked_pixels() { + let (mut s, _) = open(&framed()); + let export = + |s: &Session, format: &str| crate::cmd::fileio::encode_all(doc(s), format, &json!({"useArtboards": true})).unwrap().files.remove(0).1; + let (png0, svg0, pdf0, eps0) = (export(&s, "png"), export(&s, "svg"), export(&s, "pdf"), export(&s, "eps")); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let alpha = |bytes: &[u8]| image::load_from_memory(bytes).unwrap().to_rgba8().get_pixel(0, 0).0[3]; + assert_eq!((alpha(&png0), alpha(&export(&s, "png"))), (255, 0), "PNG"); + let svg_alpha = |bytes: &[u8]| svg_image(std::str::from_utf8(bytes).unwrap()).to_rgba8().get_pixel(0, 0).0[3]; + assert_eq!((svg_alpha(&svg0), svg_alpha(&export(&s, "svg"))), (255, 0), "SVG"); + let has = |bytes: &[u8], what: &[u8]| bytes.windows(what.len()).any(|w| w == what); + assert!(!has(&pdf0, b"/SMask") && has(&export(&s, "pdf"), b"/SMask"), "PDF: the image with an alpha channel"); + assert!(!has(&eps0, b"/MaskColor") && has(&export(&s, "eps"), b"/MaskColor"), "EPS: the hidden pixels masked out"); +} + +#[test] +fn the_mask_saves_and_opens_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let saved = vectorcraft_format::save_file(doc(&s)); + let back = vectorcraft_format::load(&saved).unwrap(); + assert_eq!(mask_of(&back, id), mask_of(doc(&s), id)); + let m = mask_of(&back, id).unwrap(); + assert_eq!(back.images[&m.key].bytes, doc(&s).images[&m.key].bytes, "the masked pixels are saved with it"); + assert_eq!(back.images[&m.from].bytes, doc(&s).images[&m.from].bytes, "and the image's own"); + assert_eq!(drawn(&back, id, 0, 0)[3], 0); +} + +#[test] +fn copy_and_paste_carry_the_mask() { + let (mut s, _) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + super::super::DECODES.with(|c| c.set(0)); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(super::super::DECODES.with(|c| c.get()), 0, "the masked pixels come along, so nothing is decoded to make them again"); + let id = s.doc().unwrap().selection.objects[0]; + let d = doc(&s); + let m = mask_of(d, id).expect("the pasted image keeps its mask"); + assert!(m.made_for(&image_of(d, id)) && d.images.contains_key(&m.key), "with its masked pixels"); + assert_eq!(drawn(d, id, 0, 0)[3], 0); + assert_eq!(drawn(d, id, 4, 4), RED); +} + +#[test] +fn new_pixels_take_the_mask_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + // Crop Image replaces the image's own pixels: the mask is made for the cropped ones. + let r = s.execute("object.cropImage", &json!({"rect": [0, 0, 6, 5]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap_or(id.0)); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!(m.from, im.key, "made for the cropped pixels"); + assert_eq!((drawn(d, id, 0, 0)[3], drawn(d, id, 4, 4)), (0, RED)); + // Pixels put in place by any edit (a link read again) take it too. + let blue = png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(6, 5, |x, _| image::Rgba(if x < 3 { WHITE } else { [0, 0, 200, 255] })))); + s.edit("Swap", |d, _| { + let blob = ImageBlob::png(blue); + let key = blob.content_key(); + d.images.insert(key.clone(), blob); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key; + } + Ok(()) + }) + .unwrap(); + let d = doc(&s); + assert_eq!(mask_of(d, id).unwrap().from, image_of(d, id).key); + assert_eq!((drawn(d, id, 0, 0)[3], drawn(d, id, 4, 0)), (0, [0, 0, 200, 255])); +} + +#[test] +fn a_document_read_with_new_pixels_takes_the_mask_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + let blob = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(4, 4, image::Rgba(WHITE))))); + let red = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, |x, _| image::Rgba(if x == 0 { WHITE } else { RED }))))); + let key = red.content_key(); + d.images.insert(key.clone(), red); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key.clone(); + } + assert_eq!(drawn(&d, id, 0, 0), WHITE, "until the mask is made again, the image's own pixels"); + super::refresh(None, &mut d); + assert_eq!(mask_of(&d, id).unwrap().from, key); + assert_eq!(drawn(&d, id, 0, 0)[3], 0); + // Pixels the mask would hide entirely: the mask stays, hiding nothing. + let all = blob.content_key(); + d.images.insert(all.clone(), blob); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = all.clone(); + } + super::refresh(None, &mut d); + let m = mask_of(&d, id).unwrap(); + assert_eq!((m.key.as_str(), m.from.as_str(), m.colors.as_slice()), (all.as_str(), all.as_str(), &[[255, 255, 255]][..])); + assert_eq!(drawn(&d, id, 0, 0), WHITE); +} + +/// A new document with a selected 12 × 10 image of `blob` under `key`, linked to `/nowhere/art.png`. +fn linked(key: &str, blob: ImageBlob) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let key = key.to_string(); + let id = s + .edit("Place", |d, sel| { + d.images.insert(key.clone(), blob); + let id = d.alloc_id(); + let im = ImageObject { + key, + width: 12, + height: 10, + xf: Default::default(), + link: Some(vectorcraft_doc::LinkInfo::new("/nowhere/art.png")), + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + (s, id) +} + +/// A linked image keeps its link; outputs that refer to linked files write its masked pixels, and +/// applying the mask embeds it. +#[test] +fn a_linked_image_stays_linked_and_outputs_its_masked_pixels() { + let (mut s, id) = linked("linked", ImageBlob::png(framed())); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let d = doc(&s); + assert_eq!((image_of(d, id).key.as_str(), image_of(d, id).link.map(|l| l.path)), ("linked", Some("/nowhere/art.png".to_string()))); + assert_eq!(drawn(d, id, 0, 0)[3], 0); + // Images written as files beside the SVG: the masked pixels are written, not a link to the + // linked file. + let r = s.execute("document.serialize", &json!({"format": "svg", "images": "link"})).unwrap(); + let text = r["text"].as_str().unwrap(); + assert!(!text.contains("/nowhere/art.png"), "{text}"); + assert_eq!(r["linked"].as_array().map(Vec::len), Some(1), "{r}"); + let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert_eq!(svg_image(r["text"].as_str().unwrap()).to_rgba8().get_pixel(0, 0).0[3], 0); + let m = mask_of(doc(&s), id).unwrap(); + s.execute(APPLY_COLOR_MASK, &json!({})).unwrap(); + let im = image_of(doc(&s), id); + assert_eq!((im.key, im.link), (m.key, None), "applied: the masked pixels, embedded"); +} + +/// A mask that hides nothing (no pixel matches) leaves a linked image linked in outputs, and +/// applying it only takes it off: the image keeps its pixels and its link. +#[test] +fn a_mask_that_hides_nothing_keeps_the_link() { + let (mut s, id) = linked("linked", ImageBlob::png(framed())); + let r = s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#0000ff", "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!((r["changed"].as_u64(), r["hidden"].as_u64()), (Some(1), Some(0))); + assert_eq!(mask_of(doc(&s), id).unwrap().key, "linked", "the masked pixels are the image's own"); + let r = s.execute("document.serialize", &json!({"format": "svg", "images": "link"})).unwrap(); + assert!(r["text"].as_str().unwrap().contains("/nowhere/art.png"), "{r}"); + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({})).unwrap()["changed"], 1); + let im = image_of(doc(&s), id); + assert_eq!((im.key.as_str(), im.link.map(|l| l.path), mask_of(doc(&s), id)), ("linked", Some("/nowhere/art.png".to_string()), None)); +} + +/// Color Mask refuses a linked image that shows its preview and an image too large to change, +/// with its own command's errors. +#[test] +fn color_mask_refuses_previews_and_images_too_large() { + let preview = std::sync::Arc::new(framed()); + let (mut s, id) = linked("missing", ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(COLOR_MASK) && e.contains("the linked image shows its preview: embed or relink it first"), "{e}"); + assert_eq!(mask_of(doc(&s), id), None); + let (mut s, _) = open(&crate::tests_linkspanel::png_header(10_632, 10_631)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(COLOR_MASK) && e.contains("an image of 10632 × 10631 pixels is too large to change"), "{e}"); +} + +/// Crop Image off the pixel grid cuts the image's own pixels, so the hidden colors stay in them +/// and show again once the mask is removed. +#[test] +fn cropping_off_the_pixel_grid_keeps_the_hidden_colors_in_the_pixels() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.execute("object.cropImage", &json!({"rect": [0.5, 0.5, 6, 5]})).unwrap(); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!(m.from, im.key, "the mask is made for the cut pixels"); + let own = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert!(own.pixels().all(|p| p.0[3] == 255), "the cut pixels are the image's own, white included"); + assert_eq!(drawn(d, id, 0, 0)[3], 0, "white is hidden"); + s.execute(REMOVE_COLOR_MASK, &json!({"ids": [id.0]})).unwrap(); + assert_eq!(drawn(doc(&s), id, 0, 0)[3], 255); +} + +/// Apply Color Mask on a mask made for older pixels makes it again for the image's own pixels, +/// and masked pixels of another size than the object records keep its place and size. +#[test] +fn applying_a_mask_made_for_older_pixels_makes_it_again_and_keeps_the_size() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let before = doc(&s).node(id).unwrap().geometric_bounds().unwrap(); + // Pixels twice the size, put in place without an edit, so no refresh has run. + let big = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(24, 20, |x, y| { + let block = (6..18).contains(&x) && (6..14).contains(&y); + image::Rgba(if block { RED } else { WHITE }) + })))); + let key = big.content_key(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.images.insert(key.clone(), big); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key.clone(); + } + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({})).unwrap()["changed"], 1); + let d = doc(&s); + let im = image_of(d, id); + assert_ne!(im.key, key, "the masked pixels of the new pixels"); + assert_eq!((im.width, im.height, mask_of(d, id)), (24, 20, None)); + let after = d.node(id).unwrap().geometric_bounds().unwrap(); + assert!((after.width() - before.width()).abs() < 1e-9 && (after.height() - before.height()).abs() < 1e-9, "{before:?} {after:?}"); + let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(10, 10).0), (0, RED)); +} + +/// A 12 × 10 grayscale image without alpha: white, with a black block from (3, 3) to (9, 7). +fn gray_framed() -> Vec { + png(DynamicImage::ImageLuma8(image::GrayImage::from_fn(12, 10, |x, y| { + image::Luma([if (3..9).contains(&x) && (3..7).contains(&y) { 0 } else { 255 }]) + }))) +} + +/// Masked pixels have an alpha channel: a grayscale image colorized with a process color can't +/// take a mask (or have its background removed), and a masked one can't take a process color. +#[test] +fn a_process_color_does_not_colorize_masked_gray_pixels() { + const REFUSED: &str = "a process color can't colorize a grayscale image with transparency"; + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(doc(&s).node(id).unwrap().extras().colorize.is_some(), "a gray image without alpha takes a process color"); + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + let e = s.execute(super::super::REMOVE_BACKGROUND, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); + // Black colorizes gray pixels with alpha: the mask is set, then a process color is refused. + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + assert_eq!(s.execute(COLOR_MASK, &hide_white()).unwrap()["changed"], 1); + let e = s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap_err().to_string(); + assert!(e.contains(REFUSED), "{e}"); +} + +/// Images outside the layers (a symbol's art, opacity-mask art) take their masks again too when +/// their own pixels are replaced, as Update Link replaces them there. +#[test] +fn masks_outside_the_layers_are_made_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + let masked = d.node(id).unwrap().clone(); + let red = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, |x, _| image::Rgba(if x == 0 { WHITE } else { RED }))))); + let key = red.content_key(); + d.images.insert(key.clone(), red); + let mut moved = masked.clone(); + if let NodeKind::Image(im) = &mut moved.kind { + im.key = key.clone(); + } + d.symbols.push(vectorcraft_doc::Symbol { name: "Logo".into(), art: std::sync::Arc::new(moved.clone()), dynamic: false }); + let holder_id = d.alloc_id(); + let mut holder = vectorcraft_doc::Node::new(holder_id, NodeKind::Group { children: vec![], clip: false }); + holder.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(moved, false))); + d.insert(d.default_layer(), 0, holder).unwrap(); + super::refresh(None, &mut d); + let from = |n: &vectorcraft_doc::Node| Document::color_mask_of(n).unwrap().from.clone(); + assert_eq!(from(&d.symbols.last().unwrap().art), key, "in the symbol's art"); + let art = &d.node(holder_id).unwrap().extras().mask.as_ref().unwrap().art; + assert_eq!(from(art), key, "in the opacity mask's art"); + let m = Document::color_mask_of(art).unwrap(); + let px = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(1, 0).0), (0, RED)); +} + +/// `ids` stand in for the selection: an image that is not selected is changed by its id. +#[test] +fn images_given_by_id_need_not_be_selected() { + let (mut s, id) = open(&framed()); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!( + s.execute(COLOR_MASK, &json!({"ids": [id.0], "background": "colors", "colors": "#fff", "tolerance": 0, "softness": 0})).unwrap()["changed"], + 1 + ); + assert_eq!(s.execute(REMOVE_COLOR_MASK, &json!({"ids": [id.0]})).unwrap()["changed"], 1); + s.execute(COLOR_MASK, &json!({"ids": [id.0], "background": "colors", "colors": "#fff", "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!(s.execute(APPLY_COLOR_MASK, &json!({"ids": [id.0]})).unwrap()["changed"], 1); + assert_eq!(mask_of(doc(&s), id), None); +} + +#[test] +fn what_cannot_be_masked_is_refused_and_nothing_changes() { + let (mut s, id) = open(&framed()); + let steps = undo_len(&s); + let e = s.execute(COLOR_MASK, &json!({"tolerance": 101})).unwrap_err(); + assert!(e.to_string().contains("`tolerance` must be from 0 to 100"), "{e}"); + let e = s + .execute(COLOR_MASK, &json!({"background": "colors", "colors": "#fff, #c80000", "contiguous": false, "tolerance": 0, "softness": 0})) + .unwrap_err(); + assert!(e.to_string().contains("the mask would hide every pixel"), "{e}"); + assert_eq!((mask_of(doc(&s), id), undo_len(&s)), (None, steps)); + let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [0, 0, 0, 0].repeat(8)).unwrap(); + let (mut s, _) = open(&ImageBlob::cmyk_tiff(&inks).unwrap().bytes); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err(); + assert!(e.to_string().contains("a CMYK image can't take a color mask"), "{e}"); + // Without settings, the dialog's query reads the settings and opens on the defaults. + assert_eq!(s.execute(COLOR_MASK, &json!({})).unwrap()["masked"], json!(false)); +} + +/// Remove Color Mask and Apply Color Mask refuse an object in `ids` that is not an image, as +/// Color Mask does. +#[test] +fn an_object_that_is_not_an_image_is_refused() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap(); + let steps = undo_len(&s); + for cmd in [COLOR_MASK, REMOVE_COLOR_MASK, APPLY_COLOR_MASK] { + let mut p = if cmd == COLOR_MASK { hide_white() } else { json!({}) }; + p["ids"] = json!([id.0, rect]); + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(&format!("object {rect} is not an image")), "{cmd}: {e}"); + } + assert!(mask_of(doc(&s), id).is_some()); + assert_eq!(undo_len(&s), steps); +} + +/// A mask that can't be made for the image's pixels (they are missing) is not made again on every +/// later edit: the art holding it is left as it is. +#[test] +fn a_mask_that_cannot_be_made_is_not_made_again_on_every_edit() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = "gone".into(); + } + super::refresh(None, &mut d); + assert_eq!(mask_of(&d, id).unwrap().key, "gone", "it hides nothing"); + let layer = d.layers[0].clone(); + super::refresh(None, &mut d); + assert!(std::sync::Arc::ptr_eq(&layer, &d.layers[0]), "the second pass changes nothing"); +} + +/// The original of a pattern being edited takes its masks again too. +#[test] +fn the_pattern_being_edited_takes_its_masks_again() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let mut d = doc(&s).clone(); + let mut art = d.node(id).unwrap().clone(); + let red = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, |x, _| image::Rgba(if x == 0 { WHITE } else { RED }))))); + let key = red.content_key(); + d.images.insert(key.clone(), red); + if let NodeKind::Image(im) = &mut art.kind { + im.key = key.clone(); + } + let original = vectorcraft_doc::PatternDef::new("Tile", vec![std::sync::Arc::new(art)]); + d.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: NodeId(999_999), original: Some(original) }); + super::refresh(None, &mut d); + let original = d.pattern_edit.as_ref().and_then(|e| e.original.as_ref()).unwrap(); + assert_eq!(Document::color_mask_of(&original.art[0]).unwrap().from, key); +} + +/// A linked file read again as the document opens (Update Links: Automatically) takes the mask +/// for its new pixels. +#[test] +fn a_linked_file_read_again_on_open_takes_the_mask_again() { + let dir = crate::tests_links::Folder::new("color-mask"); + let pic = dir.file("art.png"); + crate::tests_links::write(&pic, &framed()); + let mut s = crate::tests_links::session(); + let id = crate::tests_links::place(&mut s, &pic); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let saved = dir.file("art.vectorcraft"); + crate::tests_links::save(&mut s, &saved); + // New pixels in the file: blue where the block was red. + let blue = png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(12, 10, |x, y| { + image::Rgba(if (3..9).contains(&x) && (3..7).contains(&y) { [0, 0, 200, 255] } else { WHITE }) + }))); + crate::tests_links::write(&pic, &blue); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let r = crate::tests_links::open(&mut s, &saved); + assert_eq!(r["updatedLinks"][0]["ids"], json!([id.0]), "{r}"); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!(m.from, im.key, "made for the file's new pixels"); + let px = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(4, 4).0), (0, [0, 0, 200, 255])); +} + +/// The bytes of every file `format` writes for `d` with `p`. +fn written(d: &Document, format: &str, p: Value) -> Vec> { + crate::cmd::fileio::encode_all(d, format, &p).unwrap().files.into_iter().map(|f| f.1).collect() +} + +/// `out` for the opened image without a mask, with one hiding its red block, and with the mask +/// removed again: the mask changes what is written, and removing it gives the same output as +/// before. (Outputs that flatten see-through pixels over the white page show the hidden block as +/// white.) +fn written_with_the_mask(out: impl Fn(&Document) -> T) { + let (mut s, _) = open(&framed()); + let plain = out(doc(&s)); + s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#c80000", "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let masked = out(doc(&s)); + s.execute(REMOVE_COLOR_MASK, &json!({})).unwrap(); + assert_ne!(plain, masked, "the masked pixels are written"); + assert_eq!(out(doc(&s)), plain, "without the mask, the image's own"); +} + +#[test] +fn dxf_writes_the_masked_pixels() { + written_with_the_mask(|d| written(d, "dxf", json!({}))); +} + +#[test] +fn emf_and_wmf_write_the_masked_pixels() { + written_with_the_mask(|d| written(d, "emf", json!({}))); + written_with_the_mask(|d| written(d, "wmf", json!({}))); +} + +#[test] +fn legacy_ai_writes_the_masked_pixels() { + written_with_the_mask(|d| written(d, "ai", json!({"aiVersion": 7}))); +} + +#[test] +fn ai_editing_data_writes_the_masked_pixels() { + let o = vectorcraft_eps::AiEditingOptions { title: "art".into(), created: Some(0) }; + written_with_the_mask(|d| vectorcraft_eps::export_ai_editing(d, &o).unwrap().program); +} + +/// PDF/X-1a flattens a masked image, whose hidden pixels are transparency, and writes no soft +/// mask. +#[test] +fn pdf_x1a_flattens_the_hidden_pixels() { + let (mut s, _) = open(&framed()); + let export = |s: &mut Session| { + let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a", "compression": {"compressText": false}})).unwrap(); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8_lossy(&bytes).into_owned(), v["warnings"].to_string()) + }; + let (_, warnings) = export(&mut s); + assert!(!warnings.contains("flattened"), "an opaque image: {warnings}"); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let (text, warnings) = export(&mut s); + assert!(warnings.contains("flattened"), "{warnings}"); + assert!(!text.contains("/SMask")); +} + +/// SVG writes a colorized gray image with a mask as the coverage of its masked pixels, shared by +/// the objects that show it. +#[test] +fn svg_writes_a_colorized_masked_image_as_it_shows() { + let (mut s, _) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute(COLOR_MASK, &json!({"background": "colors", "colors": "#000000", "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + s.execute("edit.duplicate", &json!({})).unwrap(); + let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let text = r["text"].as_str().unwrap(); + assert_eq!(text.matches("data:image/png;base64,").count(), 1, "one shared image: {text}"); + assert_eq!(svg_image(text).to_rgba8().get_pixel(4, 4).0[3], 0, "the hidden black block covers nothing"); +} + +/// The opened masked image moved into what `wrap` makes of it (given the document, to take ids +/// from, and the image node). +fn wrapped(wrap: impl FnOnce(&mut Document, vectorcraft_doc::Node) -> vectorcraft_doc::Node) -> (Session, NodeId) { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.edit("Wrap", |d, _| { + let (parent, index, _) = d.position(id).unwrap(); + let image = (*d.remove(id)?).clone(); + let holder = wrap(d, image); + d.insert(parent, index, holder)?; + Ok(()) + }) + .unwrap(); + (s, id) +} + +/// The rectangle of the opened image's frame (12 × 10 at the origin) filled with `fill`. +fn frame(d: &mut Document, fill: vectorcraft_color::Paint) -> vectorcraft_doc::Node { + let rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 10.0)); + vectorcraft_doc::Node::path(d.alloc_id(), rect, vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0)) +} + +/// An image in a clip group of its own frame, which the canvas draws straight into the clipping +/// path, shows its masked pixels. +#[test] +fn a_clipped_image_draws_its_masked_pixels() { + let (s, id) = wrapped(|d, image| { + let clip = frame(d, vectorcraft_color::Paint::None); + vectorcraft_doc::Node::new( + d.alloc_id(), + NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(image)], clip: true }, + ) + }); + assert_eq!((drawn(doc(&s), id, 0, 0)[3], drawn(doc(&s), id, 4, 4)), (0, RED)); +} + +/// An image whose opacity mask is one white shape, which the canvas draws straight into the +/// shape, shows its masked pixels. +#[test] +fn an_image_masked_by_a_white_shape_draws_its_masked_pixels() { + let (s, id) = wrapped(|d, image| { + let white = frame(d, vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE)); + image.with_mask(Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true)))) + }); + assert_eq!((drawn(doc(&s), id, 0, 0)[3], drawn(doc(&s), id, 4, 4)), (0, RED)); +} + +/// Pasting into a document that has other pixels under the image's names renames them, and the +/// mask follows: the masked pixels come along under their new name, nothing is decoded again. +#[test] +fn paste_renames_the_masked_pixels_with_the_image() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let m = mask_of(doc(&s), id).unwrap(); + let masked = doc(&s).images[&m.key].bytes.clone(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let other = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba(RED))))); + s.edit("Taken names", |d, _| { + d.images.insert(m.key.clone(), other.clone()); + d.images.insert(m.from.clone(), other.clone()); + Ok(()) + }) + .unwrap(); + super::super::DECODES.with(|c| c.set(0)); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(super::super::DECODES.with(|c| c.get()), 0, "nothing is decoded to make the mask again"); + let id = s.doc().unwrap().selection.objects[0]; + let d = doc(&s); + let (im, pasted) = (image_of(d, id), mask_of(d, id).unwrap()); + assert!(pasted.key != m.key && pasted.from != m.from, "renamed: {pasted:?}"); + assert!(pasted.made_for(&im)); + assert_eq!(d.images[&pasted.key].bytes, masked); + assert_eq!((drawn(d, id, 0, 0)[3], drawn(d, id, 4, 4)), (0, RED)); +} + +/// Placing a document (as an editable copy) whose image has a mask brings the masked pixels, and +/// the mask follows names the placing document already uses for other pixels. +#[test] +fn placing_a_document_brings_the_masked_pixels() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let m = mask_of(doc(&s), id).unwrap(); + let file = vectorcraft_format::save_file(doc(&s)); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let other = ImageBlob::png(png(DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba(RED))))); + s.edit("Taken names", |d, _| { + d.images.insert(m.from.clone(), other.clone()); + Ok(()) + }) + .unwrap(); + super::super::DECODES.with(|c| c.set(0)); + let r = + s.execute("file.place", &json!({"name": "art.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file), "link": false})).unwrap(); + assert_eq!(super::super::DECODES.with(|c| c.get()), 0, "nothing is decoded to make the mask again: {r}"); + let d = doc(&s); + let mut found = vec![]; + for root in d.art_roots() { + root.walk(&mut |n| { + if let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) { + found.push(((**im).clone(), m.clone())); + } + }); + } + let [(im, placed)] = found.as_slice() else { panic!("one masked image: {found:?}") }; + assert!(placed.made_for(im) && placed.from != m.from, "the image was renamed and the mask follows: {placed:?}"); + let px = image::load_from_memory(&d.images[&placed.key].bytes).unwrap().to_rgba8(); + assert_eq!((px.get_pixel(0, 0).0[3], px.get_pixel(4, 4).0), (0, RED)); +} diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs index df27180e82..4299b2a25a 100644 --- a/crates/engine/src/cmd/addons/image/tests.rs +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -343,7 +343,7 @@ fn the_color_profile_is_kept() { encoder.set_icc_profile(icc.clone()).unwrap(); DynamicImage::ImageRgba8(framed()).write_with_encoder(encoder).unwrap(); let blob = ImageBlob::png(bytes); - let out = super::clear(&blob, &settings(Background::Edges), None).unwrap().unwrap(); + let out = super::clear(&blob, &settings(Background::Edges), None, super::C).unwrap().unwrap(); let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(out.blob.bytes.as_slice())).unwrap(); assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); } diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index b1377d2fef..7ba8d46781 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -119,6 +119,10 @@ fn node_refs(n: &Node, f: Visit) { if let Some(mask) = &m.extras().mask { node_refs(&mask.art, f); } + // A color-masked image's masked pixels go with it. + if let Some(cm) = Document::color_mask_of(m) { + f(Ref::Name(Res::Image, &cm.key)); + } match &m.kind { NodeKind::Text(t) => { for r in &t.runs { @@ -648,6 +652,10 @@ impl Relink { if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { self.node(Arc::make_mut(&mut m.art)); } + if let Some(cm) = n.extras_if_any().and_then(|e| e.color_mask.as_deref_mut()) { + self.name(Res::Image, &mut cm.key); + self.name(Res::Image, &mut cm.from); + } match &mut n.kind { NodeKind::Text(t) => { for r in &mut t.runs { diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index cf7bdb7baa..4ad89c773b 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -76,12 +76,13 @@ pub(crate) fn see_through_images(doc: &Document) -> BTreeSet { doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect() } -/// `n` shows an image of `keys` (itself or inside). +/// `n` shows an image of `keys` (itself or inside), a color-masked image's masked pixels counted as +/// what it shows. pub(super) fn draws_image(n: &Node, keys: &BTreeSet) -> bool { !keys.is_empty() && n.visible && match &n.kind { - NodeKind::Image(im) => keys.contains(&im.key), + NodeKind::Image(im) => keys.contains(&im.key) || Document::color_mask_of(n).is_some_and(|m| m.made_for(im) && keys.contains(&m.key)), _ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))), } } diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index a5881c76ef..16cf8e5de7 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -155,7 +155,8 @@ impl FlattenOptions { /// the paint ([`vectorcraft_doc::ImageBlob::takes_colorize`]) never counts: it draws uncolorized. pub(crate) fn counts_colorize(&self, d: &Document, n: &Node) -> bool { let NodeKind::Image(im) = &n.kind else { return true }; - let blob = d.image_blob(im); + let shown = d.shown_image(n, im); + let blob = d.image_blob(&shown); if blob.is_some_and(|b| !b.takes_colorize()) { return false; } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..e8413282b3 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1058,6 +1058,8 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps }; Nested { pick: &pick, make: &mut make, next: 0 }.run(d); } + // Color masks of images whose files were read again are made for the new pixels. + super::refresh_color_masks(None, d); out } diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 1dd1548363..d8d35e4423 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -1043,6 +1043,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { bare.blend = Default::default(); bare.appearance = Appearance::default().into(); bare.visible = true; + // The image's own pixels are cut, not the masked ones a color mask shows: the mask is made + // again for the cut pixels after the edit. + if let Some(e) = bare.extras_if_any() { + e.color_mask = None; + } let tmp = isolated_doc(&st.doc, vec![bare]); let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale); let (png, w, h, warning) = diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index b199f9a356..31cb5c72e6 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -701,7 +701,9 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: out.push(*id); continue; } - let Some(gray) = d.image_blob(im).and_then(|b| b.gray_source()) else { continue }; + // A color-masked image is colorized as it shows: its masked pixels, which have an alpha + // channel. + let Some(gray) = d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) else { continue }; match paint { // Without a color, `none` goes to the image's fill item. Paint::None => continue, @@ -740,7 +742,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) && !refused && !colorizes_transparency(d, paint) - && d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) + && d.image_blob(&d.shown_image(m, im)).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) { refused = true; } diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index fb0622632e..15a39f06e5 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -78,6 +78,12 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) { NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)), _ => {} } + // A color-masked image's masked pixels come along, and the key they were made from + // follows the image's. + if let Some(cm) = n.extras_if_any().and_then(|e| e.color_mask.as_deref_mut()) { + f(Ref::Image(&mut cm.key)); + f(Ref::Image(&mut cm.from)); + } }); } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4c9abdb357..47e8e0db50 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -33,7 +33,10 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.swatchLegend", "addon.contour.set", "addon.cutting.bridges", + "addon.image.applyColorMask", + "addon.image.colorMask", "addon.image.removeBackground", + "addon.image.removeColorMask", "addon.pathtools.addPoints", "addon.pathtools.close", "addon.pathtools.corner", diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index c7d22ab6c7..0f3fc8e70b 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -31,6 +31,8 @@ impl Session { } // Pixel-aligned objects the edit changed go back on the pixel grid. cmd::refresh_pixel_aligned(&before, doc); + // Color masks of images whose own pixels the edit replaced are made for the new ones. + cmd::refresh_color_masks(Some(&*before), doc); let placed = cmd::objects_on_path::refresh(&before, doc); // Pixel-aligned members that placing moved go back on the pixel grid. cmd::keep_aligned_in(doc, &placed); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index dea4ad0c24..3455aa2cca 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -635,7 +635,7 @@ impl<'a> Writer<'a> { } NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), - NodeKind::Image(im) => self.image(im, xy), + NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im), xy), NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { if !self.open_group("u") { diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 5e058acaf6..f7ed36f4ff 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -172,7 +172,7 @@ pub fn writes_image_mask(doc: &Document, n: &Node) -> bool { matches!(n.extras().colorize.as_deref(), Some(Paint::Solid { .. })) && !n.extras().mask.as_ref().is_some_and(|m| !m.disabled) && doc - .image_blob(im) + .image_blob(&doc.shown_image(n, im)) .is_some_and(|b| b.takes_colorize() && fits(&b.bytes) && b.coverage().is_some_and(|(_, _, c)| c.iter().all(|&v| v == 0 || v == 255))) } diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 3373330892..d133208b06 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -265,7 +265,8 @@ impl<'a> Writer<'a> { } NodeKind::Image(im) => { self.lock(locked); - let blob = self.doc.image_blob(im); + let shown = self.doc.shown_image(n, im); + let blob = self.doc.image_blob(&shown); // A colorize color the renderer draws the image in. let colorize = n.extras().colorize.as_deref().filter(|p| matches!(p, Paint::Solid { .. }) && blob.is_some_and(ImageBlob::takes_colorize)); diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 96205aa7d3..66f182b1e9 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -363,6 +363,8 @@ impl<'a> Scene<'a> { } NodeKind::Text(t) => self.text(n, t), NodeKind::Image(im) => { + let shown = self.doc.shown_image(n, im); + let im = &*shown; let blob = self.doc.image_blob(im); match n.extras().colorize.as_deref() { Some(p @ Paint::Solid { .. }) if blob.is_none_or(|b| b.gray_source().is_some()) => { diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 343b422093..c601c71840 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -213,7 +213,7 @@ impl<'a> Scene<'a> { self.node(&o, true); } } - NodeKind::Image(im) => self.image(im), + NodeKind::Image(im) => self.image(&self.doc.shown_image(n, im)), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 2143ecba61..c377b9f7e4 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -165,6 +165,9 @@ impl Exporter<'_> { pub(super) fn masked_image(&mut self, im: &vectorcraft_doc::ImageObject, m: &vectorcraft_doc::OpacityMask, interpolate: bool) -> Option { let art = &m.art; let NodeKind::Image(mi) = &art.kind else { return None }; + // A color-masked image in the mask's art masks with the pixels it shows. + let shown = self.doc.shown_image(art, mi); + let mi = &*shown; let plain = art.opacity >= 1.0 && art.blend == BlendMode::Normal && art.extras().mask.is_none() && !art.appearance.effects.iter().any(|e| e.visible); if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 90b7ae5248..4c97f99812 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -249,8 +249,9 @@ impl Exporter<'_> { self.shape(s, n, &bp, *rule, page); } NodeKind::Text(t) => self.text(s, n, t, page), - NodeKind::Image(im) => match n.extras().colorize.as_deref() { - Some(p @ Paint::Solid { .. }) if self.doc.image_blob(im).is_none_or(vectorcraft_doc::ImageBlob::takes_colorize) => { + NodeKind::Image(im) => match (n.extras().colorize.as_deref(), self.doc.shown_image(n, im)) { + (Some(p @ Paint::Solid { .. }), im) if self.doc.image_blob(&im).is_none_or(vectorcraft_doc::ImageBlob::takes_colorize) => { + let im = &*im; if writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { if let Some(colorized) = self.colorized_image(im, p, !n.extras().pixelated) { self.image(s, im, Some(colorized), n.extras().pixelated); @@ -263,7 +264,7 @@ impl Exporter<'_> { self.warn(super::exporter::UNWRITABLE_IMAGE); } } - _ => self.image(s, im, alpha, n.extras().pixelated), + (_, im) => self.image(s, &im, alpha, n.extras().pixelated), }, NodeKind::SymbolInstance { symbol, xf } => { // Within the objects a frame may draw of symbol instances, as on screen: nested diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index a46d1137a7..c1e239f57c 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -167,9 +167,14 @@ fn keep_colorized_pixels(n: &mut Arc, images: &BTreeMap { node.extras_mut().colorize = None; } + // A colorized image with a color mask is separated as it shows: its masked pixels. + let mask = if colorized(node) { node.extras_if_any().and_then(|e| e.color_mask.take()) } else { None }; if colorized(node) && let NodeKind::Image(im) = &mut node.kind { + if let Some(m) = mask.filter(|m| m.made_for(im) && images.contains_key(&m.key)) { + im.key = m.key; + } keys.insert(im.key.clone()); im.key = colorized_key(&im.key); } diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 6abe2f3885..2ad5ab8bba 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -380,6 +380,11 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_ /// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid /// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none). fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec>>) { + export_smasks(&masked_doc(mask, clip, invert)) +} + +/// A document of a 2 × 2 CMYK image masked by PNG `mask` (key `alpha`) on its own pixel grid. +fn masked_doc(mask: Vec, clip: bool, invert: bool) -> Document { let ink = [0u8, 102, 255, 0]; let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, flat(ink, 4)).unwrap()).unwrap()); @@ -400,8 +405,14 @@ fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec (Vec, Vec>>) { let press = builtin_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings; - let out = export_with_report(&d, &PdfOptions { settings: press, created: Some(1_700_000_000), ..Default::default() }).unwrap(); + let out = export_with_report(d, &PdfOptions { settings: press, created: Some(1_700_000_000), ..Default::default() }).unwrap(); let file = Pdf::new(Arc::new(out.bytes.clone())).unwrap(); let smasks = file .objects() @@ -440,6 +451,34 @@ fn a_cmyk_image_masked_by_a_grey_image_of_its_grid_writes_it_as_its_smask() { assert!(String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "a luminosity soft mask"); } +/// A grey mask image with a color mask masks the CMYK image with the pixels it shows: its hidden +/// pixels are clear in the image's `/SMask`. +#[test] +fn a_color_masked_grid_mask_image_writes_the_pixels_it_shows_as_the_smask() { + let mut d = masked_doc(png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()), true, false); + // The last pixel hidden. + d.images.insert( + "shown".into(), + ImageBlob::new("image/png", png(image::GrayAlphaImage::from_raw(2, 2, vec![255, 255, 128, 255, 0, 255, 64, 0]).unwrap())), + ); + let id = d.layers[0].children().unwrap()[0].id; + let mut mask = d.node(id).unwrap().extras().mask.clone().unwrap(); + let art = std::sync::Arc::make_mut(&mut mask.art); + art.extras_mut().color_mask = Some(Box::new(vectorcraft_doc::color_mask::ColorMask { + key: "shown".into(), + from: "alpha".into(), + background: vectorcraft_doc::color_mask::MaskBackground::Colors, + colors: vec![[64, 64, 64]], + contiguous: false, + measure: vectorcraft_doc::color_mask::MaskMatch::Distance, + tolerance: 0.0, + softness: 0.0, + })); + d.node_mut(id).unwrap().extras_mut().mask = Some(mask); + let (_, smasks) = export_smasks(&d); + assert_eq!(smasks, [Some(vec![255, 128, 0, 0])]); +} + /// A pixelated CMYK image (an envelope's with Anti-Alias off) masked by a grey image of its grid /// is written without /Interpolate, as one without a mask is. #[test] diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index f81fc17514..4f2033609b 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -168,6 +168,40 @@ fn separations_print_the_image_on_its_ink_plate() { } } +/// Printed separations separate a colorized image with a color mask as it shows: the pixels the +/// mask hides print on no plate. +#[test] +fn separations_print_a_color_masked_image_as_it_shows() { + let spot = Paint::Solid { color: Color::cmyk(0.0, 1.0, 1.0, 0.0), swatch: Some("Ink".into()), tint: 1.0 }; + let mut d = doc(ColorMode::Cmyk, Some(spot)); + // The black pixel hidden. + let mut png = vec![]; + image::GrayAlphaImage::from_raw(2, 1, vec![0, 0, 255, 255]) + .unwrap() + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + d.images.insert("shown".into(), ImageBlob::png(png)); + let id = d.layers[0].children().unwrap()[0].id; + d.node_mut(id).unwrap().extras_mut().color_mask = Some(Box::new(vectorcraft_doc::color_mask::ColorMask { + key: "shown".into(), + from: "pic".into(), + background: vectorcraft_doc::color_mask::MaskBackground::Colors, + colors: vec![[0, 0, 0]], + contiguous: false, + measure: vectorcraft_doc::color_mask::MaskMatch::Distance, + tolerance: 0.0, + softness: 0.0, + })); + let settings: PrintSettings = serde_json::from_value(serde_json::json!({"output": {"mode": "separations"}})).unwrap(); + let job = plan(&d, &PrintOptions { settings, ..Default::default() }).unwrap(); + assert!(!job.plates.is_empty()); + for p in &job.plates { + let n = &p.layers[0].children().unwrap()[0]; + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + assert_eq!(p.image_blob(im).and_then(|b| b.coverage()).unwrap().2, [0, 0], "the hidden black pixel prints on no plate"); + } +} + /// A linked image showing the missing-link placeholder is written in the placeholder's own colors /// though it keeps a colorize paint for its file, and printed separations separate it as an /// uncolorized image. diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs index a604481dd4..76b3bf53f2 100644 --- a/crates/render/src/renderer/images.rs +++ b/crates/render/src/renderer/images.rs @@ -11,7 +11,7 @@ use crate::images; pub(super) fn image_tint(doc: &Document, n: &Node, ink: Ink) -> Option<[u8; 3]> { let c = n.extras().colorize.as_deref()?.color()?; if let NodeKind::Image(im) = &n.kind - && doc.image_blob(im).is_some_and(|b| !b.takes_colorize()) + && doc.image_blob(&doc.shown_image(n, im)).is_some_and(|b| !b.takes_colorize()) { return None; } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index ca6611ea5f..47f2dec770 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -250,7 +250,7 @@ impl Renderer { && let Some(region) = self.clip_of(&m.art) && shape_masked_image(a, im, ®ion.0) { - self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(f.doc, a, f.ink), a.extras().pixelated); + self.draw_image_in(ctx, f, &f.doc.shown_image(a, im), Some(®ion), images::image_tint(f.doc, a, f.ink), a.extras().pixelated); self.stats.drawn += 1; return; } @@ -518,7 +518,8 @@ impl Renderer { && let NodeKind::Image(im) = &image.kind && plain_image(image, im, ®ion.0) { - self.draw_image_in(ctx, f, im, Some(®ion), images::image_tint(f.doc, image, f.ink), image.extras().pixelated); + let im = f.doc.shown_image(image, im); + self.draw_image_in(ctx, f, &im, Some(®ion), images::image_tint(f.doc, image, f.ink), image.extras().pixelated); self.stats.drawn += 1; return; } @@ -568,7 +569,9 @@ impl Renderer { } } NodeKind::Text(t) => self.draw_text(ctx, f, n, t), - NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(f.doc, n, f.ink), n.extras().pixelated), + NodeKind::Image(im) => { + self.draw_image_in(ctx, f, &f.doc.shown_image(n, im), None, images::image_tint(f.doc, n, f.ink), n.extras().pixelated) + } NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { // The instance draws when the frame has room for its art's objects; a symbol diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 3ab4003d7d..9a035e9eb9 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -748,6 +748,8 @@ impl Writer<'_> { } NodeKind::Text(t) => self.text_node(n, t), NodeKind::Image(im) => { + let shown = self.doc.shown_image(n, im); + let im = &*shown; if let Some(Paint::Solid { color, .. }) = n.extras().colorize.as_deref() && let Some(blob) = self.doc.image_blob(im) && blob.gray_source().is_some() diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index e982ab315c..cce0ac41a0 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -42,6 +42,8 @@ impl Writer<'_> { art.walk(&mut |n| { let NodeKind::Image(im) = &n.kind else { return }; let Some(Paint::Solid { color, .. }) = n.extras().colorize.as_deref() else { return }; + let shown = self.doc.shown_image(n, im); + let im = &*shown; if self.doc.image_blob(im).is_none_or(|blob| blob.gray_source().is_none()) { return; } @@ -176,12 +178,14 @@ impl Writer<'_> { let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); art.walk(&mut |c| { if let NodeKind::Image(im) = &c.kind { + let shown = self.doc.shown_image(c, im); + let im = &*shown; if matches!(c.extras().colorize.as_deref(), Some(Paint::Solid { .. })) && self.doc.image_blob(im).is_none_or(|b| b.gray_source().is_some()) { return; } - seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; + seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, im.clone())).0 += 1; } }); let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); diff --git a/crates/ui-egui/src/addons/image.rs b/crates/ui-egui/src/addons/image.rs index f8aa72b8a1..9388b104e2 100644 --- a/crates/ui-egui/src/addons/image.rs +++ b/crates/ui-egui/src/addons/image.rs @@ -1,6 +1,7 @@ //! Image Tools in the interface: its submenu (Object › Plug-ins › Image Tools). Remove Background's //! menu item opens the generic command dialog (`ui.paramDialog`) on its options, with a Preview -//! (`dialogs::command`). +//! (`dialogs::command`). Color Mask's opens the same dialog on the selected image's mask settings +//! (the command's report, see `menus::invoke`), or the defaults for an image without one. use serde_json::json; @@ -8,18 +9,29 @@ use crate::menus::Item; /// Remove Background's command. pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground"; -/// Remove Background › Background: what the command's `background` takes, by name. +/// Color Mask's command. +pub const COLOR_MASK: &str = "addon.image.colorMask"; +/// Remove Color Mask's command. +pub const REMOVE_COLOR_MASK: &str = "addon.image.removeColorMask"; +/// Apply Color Mask's command. +pub const APPLY_COLOR_MASK: &str = "addon.image.applyColorMask"; +/// Remove Background and Color Mask › Background: what the commands' `background` takes, by name. pub const BACKGROUNDS: &[(&str, &str)] = &[("Edge Color", "edges"), ("Corner Color", "corners"), ("Picked Colors", "colors")]; -/// Remove Background › Match: what the command's `match` takes, by name. +/// Remove Background and Color Mask › Match: what the commands' `match` takes, by name. pub const MATCHES: &[(&str, &str)] = &[("Distance", "distance"), ("Each Channel", "channels")]; /// Object › Plug-ins › Image Tools. pub fn menu() -> Vec { - vec![Item::Cmd( - "Remove Background…", - REMOVE_BACKGROUND, - json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10, "softness": 10}), - )] + vec![ + Item::Cmd( + "Remove Background…", + REMOVE_BACKGROUND, + json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10, "softness": 10}), + ), + Item::Cmd("Color Mask…", COLOR_MASK, json!({})), + Item::Cmd("Remove Color Mask", REMOVE_COLOR_MASK, json!({})), + Item::Cmd("Apply Color Mask", APPLY_COLOR_MASK, json!({})), + ] } #[cfg(test)] diff --git a/crates/ui-egui/src/addons/image/tests.rs b/crates/ui-egui/src/addons/image/tests.rs index 64563e611b..67b5b48ec5 100644 --- a/crates/ui-egui/src/addons/image/tests.rs +++ b/crates/ui-egui/src/addons/image/tests.rs @@ -41,13 +41,75 @@ fn alphas(app: &VectorcraftApp) -> [u8; 3] { } fn open(app: &mut VectorcraftApp) { - let entries = menus::menu_entries(app); - let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Image Tools"] && e.label == "Remove Background…").unwrap(); - assert!(item.enabled); + click(app, "Remove Background…"); +} + +/// Choose Object › Plug-ins › Image Tools › `label`, which must be enabled. +fn click(app: &mut VectorcraftApp, label: &str) { + let item = menu_item(app, label); + assert!(item.enabled, "{label} enabled"); let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); menus::invoke(app, &id, p); } +fn menu_item(app: &VectorcraftApp, label: &str) -> menus::MenuEntry { + menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Plug-ins", "Image Tools"] && e.label == label).unwrap() +} + +/// The selected image's color mask, if it has one. +fn mask(app: &VectorcraftApp) -> Option { + let st = app.session.doc().unwrap(); + st.selection.objects.first().and_then(|id| st.doc.node(*id)).and_then(vectorcraft_doc::Document::color_mask_of).cloned() +} + +/// The alpha of the selected image's masked pixels at the corner, on the ring and in the block +/// (all 255 without a mask). +fn masked_alphas(app: &VectorcraftApp) -> [u8; 3] { + let Some(m) = mask(app) else { return [255; 3] }; + let px = image::load_from_memory(&app.session.doc().unwrap().doc.images[&m.key].bytes).unwrap().to_rgba8(); + [(0, 0), (1, 1), (4, 4)].map(|(x, y)| px.get_pixel(x, y).0[3]) +} + +/// Object › Plug-ins › Image Tools › Color Mask… opens the command dialog on the defaults, previews +/// a mask while the image's pixels stay, and keeps it on OK as one step; opened again it shows the +/// image's mask settings. Remove Color Mask and Apply Color Mask are enabled only for an image +/// with a mask. +#[test] +fn color_mask_previews_keeps_its_settings_and_comes_off() { + let mut app = app(); + let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let (start, n) = (image(&app), steps(&app)); + assert!(!menu_item(&app, "Remove Color Mask").enabled && !menu_item(&app, "Apply Color Mask").enabled); + + click(&mut app, "Color Mask…"); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), (super::COLOR_MASK.to_string(), "Color Mask".to_string())); + assert_eq!((d.str("background"), d.f64("tolerance", -1.0), d.bool("preview")), ("edges".to_string(), 10.0, false)); + assert!(!d.fields.contains_key("masked")); + let shown = show(&mut app); + for s in ["Color Mask", "Edge Color", "Contiguous", "Match", "Tolerance", "Softness", "Preview"] { + assert!(shown.contains(s), "{s} in {shown}"); + } + set(&mut app, "preview", json!(true)); + set(&mut app, "tolerance", json!(20)); + show(&mut app); + assert_eq!(masked_alphas(&app), [0, 0, 255], "the preview hides the white and the ring"); + assert_eq!(image(&app), start, "the image's pixels stay"); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((steps(&app), image(&app)), (n + 1, start.clone()), "one undo step; the pixels as they were"); + assert_eq!(mask(&app).map(|m| m.tolerance), Some(20.0)); + + // Opened again: the image's mask settings. + click(&mut app, "Color Mask…"); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("tolerance", -1.0), 20.0); + crate::dialogs::cancel(&mut app); + assert_eq!(steps(&app), n + 1); + + assert!(menu_item(&app, "Apply Color Mask").enabled); + click(&mut app, "Remove Color Mask"); + assert_eq!((mask(&app), image(&app), steps(&app)), (None, start, n + 2)); +} + fn show(app: &mut VectorcraftApp) -> String { painted_text(app, |app, ui| crate::dialogs::show(app, ui.ctx())) } diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 7c9aaf6873..67a1074b85 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -64,12 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec { /// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the /// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does /// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path -/// works the same way (#5532), and so does Image Tools › Remove Background. (Cancel rolls a -/// preview back whichever dialog started it.) +/// works the same way (#5532), and so do Image Tools › Remove Background and Color Mask. (Cancel +/// rolls a preview back whichever dialog started it.) fn previews(d: &Dialog) -> bool { match d.str("__command").as_str() { "object.repeat.options" => d.fields.contains_key("gridType"), - "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true, + "type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK => true, _ => false, } } @@ -119,7 +119,9 @@ fn order(command: &str) -> &'static [&'static str] { "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], "addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"], crate::addons::typetools::RELEASE => &["mode", "keepOriginal"], - crate::addons::image::REMOVE_BACKGROUND => &["background", "colors", "contiguous", "match", "tolerance", "softness"], + crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK => { + &["background", "colors", "contiguous", "match", "tolerance", "softness"] + } crate::addons::cutting::BRIDGES => &["width", "count", "direction", "keepOriginal"], _ => &[], } @@ -141,8 +143,8 @@ fn choices(command: &str, key: &str) -> Option { ("graph.setType", "pieLegend") => Some(PIE_LEGEND), ("graph.setType", "piePosition") => Some(PIE_POSITION), ("graph.setType", "pieSort") => Some(PIE_SORT), - (crate::addons::image::REMOVE_BACKGROUND, "background") => Some(crate::addons::image::BACKGROUNDS), - (crate::addons::image::REMOVE_BACKGROUND, "match") => Some(crate::addons::image::MATCHES), + (crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK, "background") => Some(crate::addons::image::BACKGROUNDS), + (crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK, "match") => Some(crate::addons::image::MATCHES), _ => None, } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e666fcc8c6..a82ccaa3e6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -696,6 +696,10 @@ font-technology Variable Променливи Corner Color Цвят на ъглите Contiguous Съседни Each Channel Всеки канал + Color Mask… Цветова маска… + Color Mask Цветова маска + Remove Color Mask Премахване на цветовата маска + Apply Color Mask Прилагане на цветовата маска Edit Path Редактиране на контура Mask Маска Crop Marks Знаци за изрязване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2381deb135..14cd541b13 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -206,6 +206,10 @@ Corner Color Boja uglova Contiguous Susjedni Each Channel Svaki kanal + Color Mask… Maska boja… + Color Mask Maska boja + Remove Color Mask Ukloni masku boja + Apply Color Mask Primijeni masku boja Edit Path Uredi putanju Mask Maska Rasterize… Rasteriziraj… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6dfad587..d12c9e81b6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1006,6 +1006,10 @@ antiAlias Sharp Nítid Corner Color Color de les cantonades Contiguous Contigu Each Channel Cada canal + Color Mask… Màscara de color… + Color Mask Màscara de color + Remove Color Mask Elimina la màscara de color + Apply Color Mask Aplica la màscara de color Crop Marks Marques de retall Crop To Retalla fins a Cross Creu @@ -5827,6 +5831,10 @@ digits Malayalam Malaiàlam @msg a CMYK image has no transparency to set una imatge CMYK no té transparència per establir @msg every pixel matches the background: no image would be left cada píxel coincideix amb el fons: no quedaria cap imatge @msg an image of {w} × {h} pixels is too large to change una imatge de {w} × {h} píxels és massa gran per canviar-la +@msg select an image with a color mask selecciona una imatge amb una màscara de color +@msg the color mask's settings are out of range la configuració de la màscara de color és fora de l’interval +@msg a CMYK image can't take a color mask una imatge CMYK no pot tenir una màscara de color +@msg the mask would hide every pixel la màscara amagaria tots els píxels @msg select an Image Trace object selecciona un objecte d’Image Trace @msg select an Intertwine group selecciona un grup Intertwine @msg select an object in perspective selecciona un objecte en perspectiva diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 00de3e7057..5c6c8feb19 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -212,6 +212,10 @@ Corner Color Barva rohů Contiguous Souvislé Each Channel Každý kanál + Color Mask… Barevná maska… + Color Mask Barevná maska + Remove Color Mask Odstranit barevnou masku + Apply Color Mask Použít barevnou masku Edit Path Upravit cestu Mask Maska Rasterize… Rastrovat… @@ -4978,6 +4982,10 @@ imageTrace Outline Obrys @msg a CMYK image has no transparency to set obraz CMYK nemá průhlednost, kterou by bylo možné nastavit @msg every pixel matches the background: no image would be left každý obrazový bod odpovídá pozadí: nezbyl by žádný obraz @msg an image of {w} × {h} pixels is too large to change obraz o velikosti {w} × {h} obrazových bodů je příliš velký na změnu +@msg select an image with a color mask vyberte obraz s barevnou maskou +@msg the color mask's settings are out of range nastavení barevné masky jsou mimo rozsah +@msg a CMYK image can't take a color mask obraz CMYK nemůže mít barevnou masku +@msg the mask would hide every pixel maska by skryla všechny obrazové body @msg select an object in perspective vyberte objekt v perspektivě @msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení @msg Recolor completed; {locked} and {hidden} were left unchanged Přebarvení dokončeno; beze změny: {locked} a {hidden} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f48e9d6ea3..29e91d0ed8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -199,6 +199,10 @@ Corner Color Hjørnefarve Contiguous Sammenhængende Each Channel Hver kanal + Color Mask… Farvemaske… + Color Mask Farvemaske + Remove Color Mask Fjern farvemaske + Apply Color Mask Anvend farvemaske Edit Path Redigér kurve Mask Maskér Rasterize… Rasterisér… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0de0dce0b9..2b156aab58 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -689,6 +689,10 @@ font-technology Variable Variabel Corner Color Eckfarbe Contiguous Zusammenhängend Each Channel Jeder Kanal + Color Mask… Farbmaske… + Color Mask Farbmaske + Remove Color Mask Farbmaske entfernen + Apply Color Mask Farbmaske anwenden Edit Path Pfad bearbeiten Mask Maske Crop Marks Schnittmarken @@ -4595,6 +4599,10 @@ theme Appearance Erscheinungsbild @msg a CMYK image has no transparency to set ein CMYK-Bild hat keine Transparenz, die festgelegt werden kann @msg every pixel matches the background: no image would be left jedes Pixel entspricht dem Hintergrund: Es würde kein Bild übrig bleiben @msg an image of {w} × {h} pixels is too large to change ein Bild mit {w} × {h} Pixeln ist zu groß, um geändert zu werden +@msg select an image with a color mask wählen Sie ein Bild mit einer Farbmaske aus +@msg the color mask's settings are out of range die Einstellungen der Farbmaske liegen außerhalb des zulässigen Bereichs +@msg a CMYK image can't take a color mask ein CMYK-Bild kann keine Farbmaske erhalten +@msg the mask would hide every pixel die Maske würde jedes Pixel ausblenden @msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus @msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus @msg Recolor completed; {locked} and {hidden} were left unchanged Neufärben abgeschlossen; unverändert: {locked} und {hidden} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a75edff7a8..28f7a3775e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -670,6 +670,10 @@ font-technology Variable Μεταβλητές Corner Color Χρώμα γωνιών Contiguous Συνεχόμενα Each Channel Κάθε κανάλι + Color Mask… Μάσκα χρώματος… + Color Mask Μάσκα χρώματος + Remove Color Mask Αφαίρεση μάσκας χρώματος + Apply Color Mask Εφαρμογή μάσκας χρώματος Edit Path Επεξεργασία διαδρομής Mask Μάσκα Crop Marks Σημάδια ξακρίσματος @@ -4619,6 +4623,10 @@ digits Malayalam Μαλαγιάλαμ @msg a CMYK image has no transparency to set μια εικόνα CMYK δεν έχει διαφάνεια για να οριστεί @msg every pixel matches the background: no image would be left κάθε εικονοστοιχείο ταιριάζει με το φόντο: δεν θα έμενε καμία εικόνα @msg an image of {w} × {h} pixels is too large to change η εικόνα {w} × {h} εικονοστοιχείων είναι υπερβολικά μεγάλη για να αλλάξει +@msg select an image with a color mask επιλέξτε εικόνα με μάσκα χρώματος +@msg the color mask's settings are out of range οι ρυθμίσεις της μάσκας χρώματος είναι εκτός εύρους +@msg a CMYK image can't take a color mask μια εικόνα CMYK δεν μπορεί να έχει μάσκα χρώματος +@msg the mask would hide every pixel η μάσκα θα έκρυβε κάθε εικονοστοιχείο @msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική @msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό @msg Recolor completed; {locked} and {hidden} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {locked} και {hidden} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6d548f6743..40fb7474b3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -710,6 +710,10 @@ font-technology Variable Variables Corner Color Color de las esquinas Contiguous Contiguo Each Channel Cada canal + Color Mask… Máscara de color… + Color Mask Máscara de color + Remove Color Mask Quitar máscara de color + Apply Color Mask Aplicar máscara de color Edit Path Editar trazado Mask Máscara Crop Marks Marcas de corte @@ -4670,6 +4674,10 @@ axis Both Ambos @msg a CMYK image has no transparency to set una imagen CMYK no tiene transparencia que establecer @msg every pixel matches the background: no image would be left todos los píxeles coinciden con el fondo: no quedaría ninguna imagen @msg an image of {w} × {h} pixels is too large to change una imagen de {w} × {h} píxeles es demasiado grande para cambiarla +@msg select an image with a color mask selecciona una imagen con una máscara de color +@msg the color mask's settings are out of range los ajustes de la máscara de color están fuera de rango +@msg a CMYK image can't take a color mask una imagen CMYK no puede tener una máscara de color +@msg the mask would hide every pixel la máscara ocultaría todos los píxeles @msg select an object in perspective selecciona un objeto en perspectiva @msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloreado completado; sin cambios: {locked} y {hidden} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7ae54d2bef..5af3c53ddd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -219,6 +219,10 @@ Corner Color Kulmien väri Contiguous Yhtenäinen Each Channel Jokainen kanava + Color Mask… Värimaski… + Color Mask Värimaski + Remove Color Mask Poista värimaski + Apply Color Mask Käytä värimaskia Edit Path Muokkaa polkua Mask Maski Rasterize… Rasteroi… @@ -4880,6 +4884,10 @@ imageTrace Outline Ääriviiva @msg a CMYK image has no transparency to set CMYK-kuvalla ei ole asetettavaa läpinäkyvyyttä @msg every pixel matches the background: no image would be left jokainen pikseli vastaa taustaa: kuvaa ei jäisi jäljelle @msg an image of {w} × {h} pixels is too large to change kuva, jonka koko on {w} × {h} pikseliä, on liian suuri muutettavaksi +@msg select an image with a color mask valitse kuva, jossa on värimaski +@msg the color mask's settings are out of range värimaskin asetukset ovat sallitun alueen ulkopuolella +@msg a CMYK image can't take a color mask CMYK-kuvalla ei voi olla värimaskia +@msg the mask would hide every pixel maski piilottaisi jokaisen pikselin @msg select an object in perspective valitse perspektiivissä oleva objekti @msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä) @msg Recolor completed; {locked} and {hidden} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {locked} ja {hidden} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 35c649f895..354a827e4f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -712,6 +712,10 @@ font-technology Variable Variables Corner Color Couleur des angles Contiguous Contigu Each Channel Chaque canal + Color Mask… Masque de couleur… + Color Mask Masque de couleur + Remove Color Mask Supprimer le masque de couleur + Apply Color Mask Appliquer le masque de couleur Edit Path Modifier le tracé Mask Masque Crop Marks Traits de coupe @@ -4659,6 +4663,10 @@ axis Both Les deux @msg a CMYK image has no transparency to set une image CMJN n’a pas de transparence à définir @msg every pixel matches the background: no image would be left chaque pixel correspond à l’arrière-plan : il ne resterait aucune image @msg an image of {w} × {h} pixels is too large to change une image de {w} × {h} pixels est trop grande pour être modifiée +@msg select an image with a color mask sélectionnez une image avec un masque de couleur +@msg the color mask's settings are out of range les réglages du masque de couleur sont hors limites +@msg a CMYK image can't take a color mask une image CMJN ne peut pas recevoir de masque de couleur +@msg the mask would hide every pixel le masque cacherait tous les pixels @msg select an object in perspective sélectionnez un objet en perspective @msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 335b0c99f0..472461e4b3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1119,6 +1119,10 @@ antiAlias Sharp शार्प Corner Color कोनों का रंग Contiguous सन्निहित Each Channel प्रत्येक चैनल + Color Mask… रंग मास्क… + Color Mask रंग मास्क + Remove Color Mask रंग मास्क हटाएँ + Apply Color Mask रंग मास्क लागू करें Crop Marks क्रॉप मार्क Crop To करने के लिए फसल Cross क्रॉस @@ -7458,6 +7462,10 @@ antiAlias Sharp शार्प @msg a CMYK image has no transparency to set CMYK इमेज में सेट करने के लिए कोई पारदर्शिता नहीं है @msg every pixel matches the background: no image would be left हर पिक्सेल पृष्ठभूमि से मेल खाता है: कोई इमेज नहीं बचेगी @msg an image of {w} × {h} pixels is too large to change {w} × {h} पिक्सेल की इमेज बदलने के लिए बहुत बड़ी है +@msg select an image with a color mask रंग मास्क वाली एक इमेज चुनें +@msg the color mask's settings are out of range रंग मास्क की सेटिंग सीमा से बाहर हैं +@msg a CMYK image can't take a color mask CMYK इमेज पर रंग मास्क नहीं लगाया जा सकता +@msg the mask would hide every pixel मास्क हर पिक्सेल को छिपा देगा @msg select an image (or an Image Trace group) एक इमेज सिलेक्ट करें (या एक इमेज ट्रेस समूह) @msg select an Image Trace object एक इमेज ट्रेस ऑब्जेक्ट चुनें @msg select an image, or pass id एक इमेज सिलेक्ट करें, या आईडी पास करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3ed22bce8..18e489625a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -911,6 +911,10 @@ font-technology Variable Változó Corner Color Sarokszín Contiguous Összefüggő Each Channel Minden csatorna + Color Mask… Színmaszk… + Color Mask Színmaszk + Remove Color Mask Színmaszk eltávolítása + Apply Color Mask Színmaszk alkalmazása Edit Path Útvonal szerkesztése Crop Marks Vágójelek Crop To Körülvágás @@ -5421,6 +5425,10 @@ imageTrace Outline Körvonal @msg a CMYK image has no transparency to set a CMYK képnek nincs beállítható átlátszósága @msg every pixel matches the background: no image would be left minden képpont egyezik a háttérrel: nem maradna kép @msg an image of {w} × {h} pixels is too large to change egy {w} × {h} pixeles kép túl nagy a módosításhoz +@msg select an image with a color mask jelölj ki egy színmaszkos képet +@msg the color mask's settings are out of range a színmaszk beállításai a tartományon kívül esnek +@msg a CMYK image can't take a color mask CMYK képre nem tehető színmaszk +@msg the mask would hide every pixel a maszk minden képpontot elrejtene @msg select an Image Trace object jelölj ki egy képvektorizálási objektumot @msg select an object in perspective jelölj ki egy perspektivikus objektumot @msg select artwork (or a colour group) to recolor jelölj ki átszínezendő grafikát (vagy színcsoportot) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 87c3f55cd5..0e2400b19a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -199,6 +199,10 @@ Corner Color Warna sudut Contiguous Bersambung Each Channel Setiap kanal + Color Mask… Masker warna… + Color Mask Masker warna + Remove Color Mask Hapus masker warna + Apply Color Mask Terapkan masker warna Edit Path Edit jalur Mask Masker Rasterize… Rasterisasi… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a6edc31792..4e935d60bc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -710,6 +710,10 @@ font-technology Variable Variabili Corner Color Colore degli angoli Contiguous Contiguo Each Channel Ogni canale + Color Mask… Maschera colore… + Color Mask Maschera colore + Remove Color Mask Rimuovi maschera colore + Apply Color Mask Applica maschera colore Edit Path Modifica tracciato Mask Maschera Crop Marks Segni di ritaglio @@ -4721,6 +4725,10 @@ axis Both Entrambi @msg a CMYK image has no transparency to set un'immagine CMYK non ha trasparenza da impostare @msg every pixel matches the background: no image would be left ogni pixel corrisponde allo sfondo: non resterebbe alcuna immagine @msg an image of {w} × {h} pixels is too large to change un'immagine di {w} × {h} pixel è troppo grande per essere modificata +@msg select an image with a color mask seleziona un'immagine con una maschera colore +@msg the color mask's settings are out of range le impostazioni della maschera colore sono fuori intervallo +@msg a CMYK image can't take a color mask un'immagine CMYK non può avere una maschera colore +@msg the mask would hide every pixel la maschera nasconderebbe ogni pixel @msg select an object in perspective seleziona un oggetto in prospettiva @msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare @msg Recolor completed; {locked} and {hidden} were left unchanged Ricolorazione completata; senza modifiche: {locked} e {hidden} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65d7721aa1..1d2c05e7f3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -241,6 +241,10 @@ digits Malayalam マラヤーラム数字 Corner Color 角の色 Contiguous 隣接 Each Channel 各チャンネル + Color Mask… カラーマスク… + Color Mask カラーマスク + Remove Color Mask カラーマスクを削除 + Apply Color Mask カラーマスクを適用 Edit Path パスを編集 Mask マスク Rasterize… ラスタライズ… @@ -4923,6 +4927,10 @@ swatch Bright Violet ビビッドバイオレット @msg a CMYK image has no transparency to set CMYK 画像には設定できる透明部分がありません @msg every pixel matches the background: no image would be left すべてのピクセルが背景に一致します: 画像が残りません @msg an image of {w} × {h} pixels is too large to change {w} × {h} ピクセルの画像は大きすぎて変更できません +@msg select an image with a color mask カラーマスクのある画像を選択してください +@msg the color mask's settings are out of range カラーマスクの設定が範囲外です +@msg a CMYK image can't take a color mask CMYK 画像にはカラーマスクを設定できません +@msg the mask would hide every pixel マスクがすべてのピクセルを隠してしまいます @msg the document has no artboards ドキュメントにアートボードがありません @msg tool (a tool id) is required tool (ツール ID) を指定してください @msg unknown tool `{t}` `{t}` は不明なツールです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index eb686a14a0..e78aff2a60 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -171,6 +171,10 @@ Corner Color კუთხეების ფერი Contiguous მომიჯნავე Each Channel თითოეული არხი + Color Mask… ფერის ნიღაბი… + Color Mask ფერის ნიღაბი + Remove Color Mask ფერის ნიღბის მოშორება + Apply Color Mask ფერის ნიღბის გამოყენება Mask ნიღაბი Rasterize… რასტრირება… Create Gradient Mesh… გრადიენტული ბადის შექმნა… @@ -4418,6 +4422,10 @@ font-technology Variable ცვლადი @msg a CMYK image has no transparency to set CMYK გამოსახულებას არ აქვს დასაყენებელი გამჭვირვალობა @msg every pixel matches the background: no image would be left ყველა პიქსელი ემთხვევა ფონს: გამოსახულება აღარ დარჩებოდა @msg an image of {w} × {h} pixels is too large to change {w} × {h} პიქსელის გამოსახულება შესაცვლელად ზედმეტად დიდია +@msg select an image with a color mask მონიშნეთ ფერის ნიღბიანი გამოსახულება +@msg the color mask's settings are out of range ფერის ნიღბის პარამეტრები დასაშვებ დიაპაზონს სცდება +@msg a CMYK image can't take a color mask CMYK გამოსახულებას ფერის ნიღაბი ვერ დაედება +@msg the mask would hide every pixel ნიღაბი ყველა პიქსელს დამალავდა @msg select an object in perspective მონიშნეთ ობიექტი პერსპექტივაში @msg select artwork (or a colour group) to recolor მონიშნეთ ხელახლა შესაფერადებელი ნახატი (ან ფერთა ჯგუფი) @msg Recolor completed; {locked} and {hidden} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {locked} და {hidden} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 52a10ab8e8..07c1cae081 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -234,6 +234,10 @@ digits Malayalam 말라얄람어 Corner Color 모퉁이 색상 Contiguous 인접 Each Channel 각 채널 + Color Mask… 색상 마스크… + Color Mask 색상 마스크 + Remove Color Mask 색상 마스크 제거 + Apply Color Mask 색상 마스크 적용 Edit Path 패스 편집 Mask 마스크 Rasterize… 래스터화… @@ -4850,6 +4854,10 @@ theme Appearance 외관 @msg a CMYK image has no transparency to set CMYK 이미지에는 설정할 투명도가 없습니다 @msg every pixel matches the background: no image would be left 모든 픽셀이 배경과 일치합니다: 남는 이미지가 없습니다 @msg an image of {w} × {h} pixels is too large to change {w} × {h}픽셀 이미지는 너무 커서 변경할 수 없습니다 +@msg select an image with a color mask 색상 마스크가 있는 이미지를 선택하세요 +@msg the color mask's settings are out of range 색상 마스크 설정이 범위를 벗어났습니다 +@msg a CMYK image can't take a color mask CMYK 이미지에는 색상 마스크를 적용할 수 없습니다 +@msg the mask would hide every pixel 마스크가 모든 픽셀을 숨기게 됩니다 @msg the document has no artboards 문서에 아트보드가 없습니다 @msg tool (a tool id) is required 도구(도구 id)가 필요합니다 @msg unknown tool `{t}` 알 수 없는 도구 `{t}` diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4f9d484155..dd79fa8c52 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -213,6 +213,10 @@ Corner Color Hoekkleur Contiguous Aaneengesloten Each Channel Elk kanaal + Color Mask… Kleurmasker… + Color Mask Kleurmasker + Remove Color Mask Kleurmasker verwijderen + Apply Color Mask Kleurmasker toepassen Edit Path Pad bewerken Mask Masker Rasterize… Rasteriseren… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e44328480f..e3c5814d1d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -176,6 +176,10 @@ Corner Color Kolor narożników Contiguous Ciągły Each Channel Każdy kanał + Color Mask… Maska koloru… + Color Mask Maska koloru + Remove Color Mask Usuń maskę koloru + Apply Color Mask Zastosuj maskę koloru Edit Path Edytuj ścieżkę Mask Maska Rasterize… Rasteryzuj… @@ -4804,6 +4808,10 @@ imageTrace Outline Kontur @msg a CMYK image has no transparency to set obraz CMYK nie ma przezroczystości, którą można ustawić @msg every pixel matches the background: no image would be left każdy piksel pasuje do tła: nie zostałby żaden obraz @msg an image of {w} × {h} pixels is too large to change obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go zmienić +@msg select an image with a color mask zaznacz obraz z maską koloru +@msg the color mask's settings are out of range ustawienia maski koloru są poza zakresem +@msg a CMYK image can't take a color mask obraz CMYK nie może mieć maski koloru +@msg the mask would hide every pixel maska ukryłaby każdy piksel @msg select an object in perspective zaznacz obiekt w perspektywie @msg select artwork (or a colour group) to recolor zaznacz kompozycję (lub grupę kolorów), aby zmienić kolory @msg Recolor completed; {locked} and {hidden} were left unchanged Zmiana kolorów zakończona; bez zmian: {locked} i {hidden} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3ce111f7a4..3784be4eec 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -636,6 +636,10 @@ antiAlias Sharp Nítido Corner Color Cor dos Cantos Contiguous Contíguo Each Channel Cada Canal + Color Mask… Máscara de Cor… + Color Mask Máscara de Cor + Remove Color Mask Remover Máscara de Cor + Apply Color Mask Aplicar Máscara de Cor Edit Path Editar caminho Mask Máscara Crop Marks Marcas de Corte @@ -4207,6 +4211,10 @@ axis Both Ambos @msg a CMYK image has no transparency to set uma imagem CMYK não tem transparência para definir @msg every pixel matches the background: no image would be left todos os pixels correspondem ao fundo: nenhuma imagem restaria @msg an image of {w} × {h} pixels is too large to change uma imagem de {w} × {h} pixels é grande demais para ser alterada +@msg select an image with a color mask selecione uma imagem com uma máscara de cor +@msg the color mask's settings are out of range as configurações da máscara de cor estão fora do intervalo +@msg a CMYK image can't take a color mask uma imagem CMYK não pode ter uma máscara de cor +@msg the mask would hide every pixel a máscara ocultaria todos os pixels @msg select an object in perspective selecione um objeto em perspectiva @msg select artwork (or a colour group) to recolor selecione uma arte-final (ou um grupo de cores) para recolorir @msg Recolor completed; {locked} and {hidden} were left unchanged Recoloração concluída; sem alteração: {locked} e {hidden} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c93807c08a..e2e3cd548a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -708,6 +708,10 @@ font-technology Variable Вариативные Corner Color Цвет углов Contiguous Смежные Each Channel Каждый канал + Color Mask… Цветовая маска… + Color Mask Цветовая маска + Remove Color Mask Удалить цветовую маску + Apply Color Mask Применить цветовую маску Edit Path Редактировать контур Mask Маска Crop Marks Метки обрезки @@ -4707,6 +4711,10 @@ axis Both Оба @msg a CMYK image has no transparency to set у изображения CMYK нет прозрачности, которую можно задать @msg every pixel matches the background: no image would be left каждый пиксель совпадает с фоном: от изображения ничего не осталось бы @msg an image of {w} × {h} pixels is too large to change изображение размером {w} × {h} пикселей слишком велико для изменения +@msg select an image with a color mask выделите изображение с цветовой маской +@msg the color mask's settings are out of range настройки цветовой маски вне допустимого диапазона +@msg a CMYK image can't take a color mask к изображению CMYK нельзя применить цветовую маску +@msg the mask would hide every pixel маска скрыла бы все пиксели @msg select an object in perspective выделите объект в перспективе @msg select artwork (or a colour group) to recolor выделите рисунок (или цветовую группу) для перекрашивания @msg Recolor completed; {locked} and {hidden} were left unchanged Перекрашивание завершено; без изменений: {locked} и {hidden} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce20b6c72d..b89236b45c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -698,6 +698,10 @@ font-technology Variable Variabla Corner Color Hörnfärg Contiguous Sammanhängande Each Channel Varje kanal + Color Mask… Färgmask… + Color Mask Färgmask + Remove Color Mask Ta bort färgmask + Apply Color Mask Tillämpa färgmask Edit Path Redigera bana Mask Maskera Crop Marks Skärmärken @@ -4567,6 +4571,10 @@ axis Both Båda @msg a CMYK image has no transparency to set en CMYK-bild har ingen transparens att ställa in @msg every pixel matches the background: no image would be left varje pixel matchar bakgrunden: ingen bild skulle bli kvar @msg an image of {w} × {h} pixels is too large to change en bild på {w} × {h} pixlar är för stor för att ändras +@msg select an image with a color mask markera en bild med en färgmask +@msg the color mask's settings are out of range färgmaskens inställningar ligger utanför intervallet +@msg a CMYK image can't take a color mask en CMYK-bild kan inte ha en färgmask +@msg the mask would hide every pixel masken skulle dölja varje pixel @msg select an object in perspective markera ett objekt i perspektiv @msg select artwork (or a colour group) to recolor markera grafik (eller en färggrupp) att färga om @msg Recolor completed; {locked} and {hidden} were left unchanged Omfärgning klar; utan ändring: {locked} och {hidden} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index db3c4501b7..8000ce5b2f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -690,6 +690,10 @@ font-technology Variable Değişken Corner Color Köşe Rengi Contiguous Bitişik Each Channel Her Kanal + Color Mask… Renk Maskesi… + Color Mask Renk Maskesi + Remove Color Mask Renk Maskesini Kaldır + Apply Color Mask Renk Maskesini Uygula Mask Maske Crop Marks Kırpma İşaretleri Crop To Kırpma Hedefi @@ -4420,6 +4424,10 @@ axis Both Her İkisi @msg a CMYK image has no transparency to set CMYK görüntüsünde ayarlanacak saydamlık yok @msg every pixel matches the background: no image would be left her piksel arka planla eşleşiyor: hiç görüntü kalmaz @msg an image of {w} × {h} pixels is too large to change {w} × {h} piksellik bir görüntü değiştirilemeyecek kadar büyük +@msg select an image with a color mask renk maskesi olan bir resim seç +@msg the color mask's settings are out of range renk maskesinin ayarları izin verilen aralığın dışında +@msg a CMYK image can't take a color mask CMYK görüntüsüne renk maskesi uygulanamaz +@msg the mask would hide every pixel maske her pikseli gizlerdi @msg select an object in perspective perspektifte bir nesne seç @msg select artwork (or a colour group) to recolor yeniden renklendirilecek resmi (veya bir renk grubunu) seçin @msg Recolor completed; {locked} and {hidden} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {locked} ve {hidden} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 151a886cf9..7209578cba 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -704,6 +704,10 @@ font-technology Variable Варіативні Corner Color Колір кутів Contiguous Суміжні Each Channel Кожен канал + Color Mask… Колірна маска… + Color Mask Колірна маска + Remove Color Mask Видалити колірну маску + Apply Color Mask Застосувати колірну маску Edit Path Редагувати контур Mask Маска Crop Marks Мітки обрізання @@ -4705,6 +4709,10 @@ axis Both Обидві @msg a CMYK image has no transparency to set зображення CMYK не має прозорості, яку можна задати @msg every pixel matches the background: no image would be left кожен піксель збігається з фоном: від зображення нічого не залишилося б @msg an image of {w} × {h} pixels is too large to change зображення розміром {w} × {h} пікселів завелике для зміни +@msg select an image with a color mask виділіть зображення з колірною маскою +@msg the color mask's settings are out of range параметри колірної маски поза допустимим діапазоном +@msg a CMYK image can't take a color mask до зображення CMYK не можна застосувати колірну маску +@msg the mask would hide every pixel маска приховала б усі пікселі @msg select an object in perspective виділіть об’єкт у перспективі @msg select artwork (or a colour group) to recolor виділіть малюнок (або колірну групу) для перефарбовування @msg Recolor completed; {locked} and {hidden} were left unchanged Перефарбовування завершено; без змін: {locked} і {hidden} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f3b505c648..93b794e127 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -811,6 +811,10 @@ font-technology Variable Biến thiên Corner Color Màu góc Contiguous Liền kề Each Channel Từng kênh + Color Mask… Mặt nạ màu… + Color Mask Mặt nạ màu + Remove Color Mask Xóa mặt nạ màu + Apply Color Mask Áp dụng mặt nạ màu Edit Path Sửa đường dẫn Crop Marks Dấu cắt xén Crop To Cắt xén đến @@ -5084,6 +5088,10 @@ theme Appearance Giao diện @msg a CMYK image has no transparency to set ảnh CMYK không có độ trong suốt để đặt @msg every pixel matches the background: no image would be left mọi điểm ảnh đều khớp với nền: sẽ không còn lại ảnh nào @msg an image of {w} × {h} pixels is too large to change ảnh {w} × {h} điểm ảnh quá lớn nên không thể thay đổi +@msg select an image with a color mask hãy chọn ảnh có mặt nạ màu +@msg the color mask's settings are out of range cài đặt của mặt nạ màu nằm ngoài phạm vi cho phép +@msg a CMYK image can't take a color mask ảnh CMYK không thể có mặt nạ màu +@msg the mask would hide every pixel mặt nạ sẽ ẩn mọi điểm ảnh @msg select an Image Trace object hãy chọn đối tượng vector hóa ảnh @msg select an object in perspective hãy chọn đối tượng trong phối cảnh @msg select artwork (or a colour group) to recolor hãy chọn tác phẩm (hoặc nhóm màu) để tô lại diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c7219b9448..89821b1212 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -655,6 +655,10 @@ antiAlias Sharp 锐利 Corner Color 转角颜色 Contiguous 连续 Each Channel 每个通道 + Color Mask… 颜色蒙版… + Color Mask 颜色蒙版 + Remove Color Mask 移除颜色蒙版 + Apply Color Mask 应用颜色蒙版 Edit Path 编辑路径 Mask 蒙版 Crop Marks 裁切标记 @@ -4615,6 +4619,10 @@ imageTrace Outline 轮廓 @msg a CMYK image has no transparency to set CMYK 图像没有可设置的透明度 @msg every pixel matches the background: no image would be left 每个像素都与背景匹配:不会留下任何图像 @msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的图像过大,无法更改 +@msg select an image with a color mask 请选择一个带颜色蒙版的图像 +@msg the color mask's settings are out of range 颜色蒙版的设置超出范围 +@msg a CMYK image can't take a color mask CMYK 图像不能使用颜色蒙版 +@msg the mask would hide every pixel 蒙版会隐藏所有像素 @msg the document has no artboards 文档没有画板 @msg tool (a tool id) is required 需要 tool (工具 id) @msg unknown tool `{t}` 未知工具 `{t}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1c753576a5..93e931564d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -672,6 +672,10 @@ font-technology Variable 可變 Corner Color 轉角顏色 Contiguous 連續 Each Channel 每個通道 + Color Mask… 顏色遮色片… + Color Mask 顏色遮色片 + Remove Color Mask 移除顏色遮色片 + Apply Color Mask 套用顏色遮色片 Edit Path 編輯路徑 Mask 遮色片 Crop Marks 裁切標記 @@ -2408,6 +2412,10 @@ file-filter Templates 範本 @msg a CMYK image has no transparency to set CMYK 影像沒有可設定的透明度 @msg every pixel matches the background: no image would be left 每個像素都與背景相符:不會留下任何影像 @msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的影像過大,無法變更 +@msg select an image with a color mask 請選取帶有顏色遮色片的影像 +@msg the color mask's settings are out of range 顏色遮色片的設定超出範圍 +@msg a CMYK image can't take a color mask CMYK 影像無法使用顏色遮色片 +@msg the mask would hide every pixel 遮色片會隱藏所有像素 @msg the document has no artboards 文件沒有工作區域 @msg tool (a tool id) is required 需要 tool (工具 id) @msg unknown tool `{t}` 未知的工具 `{t}` diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index ab530b803c..9d30b2a5b6 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -29,12 +29,13 @@ pub(crate) fn with_held_keys(target: (String, Value), held: egui::Modifiers) -> /// The dialog of command `id` that shows the selection's current values (its query): its heading /// and the queried values it doesn't show. Type on a Path Options leaves the brackets where they -/// are. +/// are. Color Mask opens on the selected image's mask settings (the defaults without one). fn queried_dialog(id: &str) -> Option<(&'static str, &'static [&'static str])> { Some(match id { // Whether the text overflows, and Shrink Text's factor, are read-only facts. "text.areaOptions" => ("Area Type Options", &["overflow", "fitScale"]), "type.pathOptions" => ("Type on a Path Options", &["start", "end"]), + crate::addons::image::COLOR_MASK => ("Color Mask", &["masked"]), _ => return None, }) } diff --git a/docs/mcp.md b/docs/mcp.md index 4ac7a10930..6099d41044 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9793,8 +9793,9 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la ## Image Tools add-on -Image Tools is a built-in add-on for placed images: its command is `addon.image.removeBackground`, and in the desktop -app it lives in Object › Plug-ins › Image Tools. It is a VectorCraft addition. +Image Tools is a built-in add-on for placed images: its commands are `addon.image.removeBackground`, +`addon.image.colorMask`, `addon.image.removeColorMask` and `addon.image.applyColorMask`, and in the desktop app it +lives in Object › Plug-ins › Image Tools. It is a VectorCraft addition. ### Remove Background @@ -9826,9 +9827,11 @@ change in one undo step, and an image that `ids` names twice changes once. An im as it is, and when none changes the command adds no undo step and returns `changed: 0`. `cleared` counts the pixels made fully clear in the images that changed. The command refuses, and changes nothing, when an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance while it is -worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), or every pixel of an -image would be cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas -shows the result for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, +worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), every pixel of an +image would be cleared, or a grayscale image colorized with a process color would get an alpha channel (a colorize +color on a grayscale image with transparency is a spot color, black or white). Remove Background… opens the command's +dialog with Preview off; with Preview on, the canvas shows the result for the values in the dialog, and Cancel puts the +image back. While a dialog previews the command, the session keeps the decoded pixels of the images it changes (at most 128 MiB, or 32 MiB in the web app), so the next preview step does not decode them again; it releases them when the dialog closes with OK or Cancel. A run that is not a preview step does not keep them. @@ -9839,6 +9842,53 @@ a preview step does not keep them. {"name":"run_command","arguments":{"command":"addon.image.removeBackground","params":{"background":"corners","contiguous":false,"match":"channels","tolerance":12.55,"softness":0}}} ``` +### Color Mask + +`addon.image.colorMask` hides colors of the selected images, or of the images in `ids`, and leaves their pixels as +they are. It takes `background`, `colors`, `contiguous`, `match`, `tolerance` and `softness` with the same values, +defaults and refusals as `addon.image.removeBackground`, and matches colors the same way. The image keeps its own +pixels. The mask is kept with the image object and saved with the document: its settings, and a PNG of the image's +pixels with the hidden colors made clear (16-bit and gray images keep their depth and channels, with the source's color +profile and resolution). PDF's color key masking hides chosen colors of an image in a similar way +([ISO 32000-1 §8.9.6.4](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)). + +- The canvas, printing and separations, PDF (PDF/X-1a and PDF/X-3 flatten the hidden pixels as transparency), SVG, + EPS, the raster formats, DXF, EMF, WMF, the legacy .ai, the .ai editing data, copy and paste, and placing a document + show the masked pixels. A linked image stays linked. An output that refers to linked files instead of embedding them + (SVG with `images: "link"`) writes the masked pixels in place of the link. +- A grayscale image's masked pixels have an alpha channel. A colorize color on a masked grayscale image follows the rule + for grayscale images with transparency (a spot color, black or white), and Color Mask refuses a grayscale image + colorized with a process color. +- A click anywhere in the image's frame selects it, hidden pixels included, as it does for an image with clear pixels. + The Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels, + hidden colors included. +- Running the command again on a masked image replaces its mask. All the images change in one undo step. An image whose + mask does not change is left as it is, and when none changes the command adds no undo step and returns `changed: 0`. + `hidden` counts the pixels made fully clear in the images that changed. +- When the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, or a linked file read + again when the document opens), the mask is made again for the new pixels. When the new pixels can't be masked (CMYK + pixels, missing pixels, or every pixel would be hidden), the mask keeps its settings and hides nothing. +- The command refuses, and changes nothing, when an object in `ids` is not an image, an image is CMYK, a linked image + shows its preview (embed or relink it first), an image would need more than the decode allowance, or the mask would + hide every pixel of an image. +- With none of `background`, `colors`, `contiguous`, `match`, `tolerance` and `softness` given, nothing changes, and + the command returns the mask settings of the first image (the defaults when it has no mask) with `masked`. Color + Mask… opens the command's dialog on those settings, with a Preview that works as Remove Background's does. + +→ `{changed, ids, hidden}`, or without settings `{background, colors, contiguous, match, tolerance, softness, masked}` + +`addon.image.removeColorMask` takes the mask off the selected images, or the images in `ids`, in one undo step, and +the images show their own pixels again. `addon.image.applyColorMask` makes each mask part of its image: the masked +pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is +taken off, in one undo step. A mask that hides nothing is only taken off, and the image keeps its pixels and its link. +Images without a mask are left as they are, and an object in `ids` that is not an image is refused. Both commands are +enabled only when a selected image has a mask. → `{changed, ids}` + +```json +{"name":"run_command","arguments":{"command":"addon.image.colorMask","params":{"background":"colors","colors":"#00ff00","contiguous":false,"tolerance":15,"softness":10}}} +{"name":"run_command","arguments":{"command":"addon.image.applyColorMask","params":{}}} +``` + ## Type Tools add-on Type Tools is a built-in add-on for type: its commands are `addon.type.textTweaker` and `addon.type.releasePath`, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..9672c6e9b1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1999,7 +1999,11 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance, results with no pixel left and grayscale images colorized with a process color are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). + +## Image Tools add-on: Color Mask (2026-11-12) + +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Color Mask… (`addon.image.colorMask`) hides colors of the selected images while their pixels stay as they are. It matches colors as Remove Background does (edge, corner or picked colors, Contiguous, Match, Tolerance and Softness, VectorCraft's own values). The mask's settings and the masked pixels are kept with the image object and saved with the document. The canvas (clip groups and white-shape opacity masks included), printing and separations, PDF (PDF/X-1a flattens the hidden pixels), SVG, EPS, PNG, DXF, EMF, WMF, the legacy .ai, the .ai editing data, copy and paste, and placing a document show the masked pixels. A linked image stays linked, and outputs that would refer to its file write the masked pixels instead. A masked grayscale image takes only a spot, black or white colorize color. The dialog opens on the image's mask settings. Remove Color Mask takes the mask off, and Apply Color Mask makes it part of the image's pixels. Each is one undo step, and none is recorded when nothing changes. When the image's own pixels are replaced (a link updated or read again on open, the image cropped or recolored), the mask is made again for the new pixels. A click anywhere in the image's frame selects it. Tests: `cmd::addons::image::mask::tests` in `vectorcraft-engine` (hidden colors drawn, pixels kept, edit, report, remove and apply, the outputs listed, save and open, copy and paste, placing a document, a linked image, cropped and replaced pixels, colorize colors, refusals), `color_mask::tests` and `placed_document::tests` in `vectorcraft-doc`, the separations and CMYK soft-mask tests in `vectorcraft-pdf`, and `addons::image::tests::color_mask_previews_keeps_its_settings_and_comes_off` in `vectorcraft-ui-egui`. Commands: `docs/mcp.md` › Image Tools add-on › Color Mask. ## Color Effects add-on: Find Nearest Swatch (2026-11-01) From 72fb8fe81d210945b29764f1304b2437846d3b61 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:27:59 -0800 Subject: [PATCH 02/22] Image Tools: masked images count as see-through in legacy .ai and pattern tiles - The legacy .ai and the check for see-through images in pattern tiles read a color-masked image's masked pixels, as the flattener does. A legacy .ai flattens a masked opaque image with the art under it, or leaves it out with `transparentImages: "omit"`, and a pattern tile that holds one is expanded before PDF/X-1a, PDF/X-3 and legacy .ai output flatten it. - Printed separations check whether a colorize paint shows on the masked pixels, as the canvas does, rather than on the image's own pixels. - A saved mask that leaves out `contiguous`, `tolerance` or `softness` reads them as the command's defaults: true, 10 and 10. - Remove Background is unchanged on this branch. Its refusal of a grayscale image colorized with a process color is left for a separate pull request. Tests: legacy_ai_flattens_a_masked_image_as_a_see_through_one, a_pattern_tile_with_a_masked_image_counts_as_see_through, pdf_x1a_and_x3_flatten_the_hidden_pixels (PDF/X-3 added), separations_colorize_masked_pixels_whose_own_pixels_are_a_preview, a_record_without_settings_reads_the_defaults, and the dialog's query on a linked image at its preview. --- crates/doc/src/color_mask.rs | 34 +++++-- crates/engine/src/cmd/addons/image.rs | 7 +- .../engine/src/cmd/addons/image/mask/tests.rs | 92 +++++++++++++++---- crates/engine/src/cmd/expand.rs | 8 +- crates/engine/src/cmd/fileio/legacy_ai.rs | 6 +- crates/pdf/src/print/plates.rs | 34 ++++--- crates/pdf/src/tests/colorize.rs | 36 ++++++++ docs/mcp.md | 8 +- docs/target-app-parity.md | 4 +- 9 files changed, 173 insertions(+), 56 deletions(-) diff --git a/crates/doc/src/color_mask.rs b/crates/doc/src/color_mask.rs index 52673d526b..8905d6fe60 100644 --- a/crates/doc/src/color_mask.rs +++ b/crates/doc/src/color_mask.rs @@ -57,19 +57,30 @@ pub struct ColorMask { /// The hidden colors (8-bit RGB) with [`MaskBackground::Colors`]. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub colors: Vec<[u8; 3]>, - /// Only the matching pixels that reach in from an edge are hidden. - #[serde(default)] + /// 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`]). - #[serde(default)] + /// 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. - #[serde(default)] + /// 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 { @@ -169,6 +180,17 @@ mod tests { 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") } diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index d36b80cbb1..e0492563b2 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -24,7 +24,6 @@ use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; -use super::super::paint::{check_colorized, check_colorized_kept}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -44,7 +43,7 @@ pub fn specs() -> Vec { "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, 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}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background )]; @@ -151,7 +150,6 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { let ids: Vec = changes.iter().map(|(id, ..)| id.0).collect(); let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum(); if !changes.is_empty() { - let colorized_ok = check_colorized(&s.doc()?.doc).is_ok(); s.edit("Remove Background", |d, _| { for (id, out, xf) in changes { let key = out.blob.content_key(); @@ -162,8 +160,7 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { im.link = None; } } - // A grayscale image colorized with a process color can't take an alpha channel. - check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) + Ok(()) })?; } Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear })) diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index 1366a3dcf5..6f6b454031 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -318,6 +318,8 @@ fn color_mask_refuses_previews_and_images_too_large() { let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); assert!(e.contains(COLOR_MASK) && e.contains("the linked image shows its preview: embed or relink it first"), "{e}"); assert_eq!(mask_of(doc(&s), id), None); + // Without settings, the dialog's query reads the settings and opens on the defaults. + assert_eq!(s.execute(COLOR_MASK, &json!({})).unwrap()["masked"], json!(false)); let (mut s, _) = open(&crate::tests_linkspanel::png_header(10_632, 10_631)); let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); assert!(e.contains(COLOR_MASK) && e.contains("an image of 10632 × 10631 pixels is too large to change"), "{e}"); @@ -378,7 +380,7 @@ fn gray_framed() -> Vec { } /// Masked pixels have an alpha channel: a grayscale image colorized with a process color can't -/// take a mask (or have its background removed), and a masked one can't take a process color. +/// take a mask, and a masked one can't take a process color. #[test] fn a_process_color_does_not_colorize_masked_gray_pixels() { const REFUSED: &str = "a process color can't colorize a grayscale image with transparency"; @@ -388,8 +390,6 @@ fn a_process_color_does_not_colorize_masked_gray_pixels() { let (before, steps) = (doc(&s).clone(), undo_len(&s)); let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); assert!(e.contains(REFUSED), "{e}"); - let e = s.execute(super::super::REMOVE_BACKGROUND, &hide_white()).unwrap_err().to_string(); - assert!(e.contains(REFUSED), "{e}"); assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); // Black colorizes gray pixels with alpha: the mask is set, then a process color is refused. s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); @@ -585,22 +585,78 @@ fn ai_editing_data_writes_the_masked_pixels() { written_with_the_mask(|d| vectorcraft_eps::export_ai_editing(d, &o).unwrap().program); } -/// PDF/X-1a flattens a masked image, whose hidden pixels are transparency, and writes no soft -/// mask. +/// PDF/X-1a and PDF/X-3 flatten a masked image, whose hidden pixels are transparency, and write no +/// soft mask. #[test] -fn pdf_x1a_flattens_the_hidden_pixels() { - let (mut s, _) = open(&framed()); - let export = |s: &mut Session| { - let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a", "compression": {"compressText": false}})).unwrap(); - let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); - (String::from_utf8_lossy(&bytes).into_owned(), v["warnings"].to_string()) - }; - let (_, warnings) = export(&mut s); - assert!(!warnings.contains("flattened"), "an opaque image: {warnings}"); - s.execute(COLOR_MASK, &hide_white()).unwrap(); - let (text, warnings) = export(&mut s); - assert!(warnings.contains("flattened"), "{warnings}"); - assert!(!text.contains("/SMask")); +fn pdf_x1a_and_x3_flatten_the_hidden_pixels() { + for standard in ["pdfX1a", "pdfX3"] { + let (mut s, _) = open(&framed()); + let export = |s: &mut Session| { + let v = s.execute("document.exportPdf", &json!({"standard": standard, "compression": {"compressText": false}})).unwrap(); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + (String::from_utf8_lossy(&bytes).into_owned(), v["warnings"].to_string()) + }; + let (_, warnings) = export(&mut s); + assert!(!warnings.contains("flattened"), "{standard}, an opaque image: {warnings}"); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let (text, warnings) = export(&mut s); + assert!(warnings.contains("flattened"), "{standard}: {warnings}"); + assert!(!text.contains("/SMask"), "{standard}"); + } +} + +/// The legacy .ai flattens a masked image, whose hidden pixels are transparency, as it flattens an +/// image whose background was removed, and `transparentImages: "omit"` leaves both out. +#[test] +fn legacy_ai_flattens_a_masked_image_as_a_see_through_one() { + let warnings = |s: &Session, p: &Value| crate::cmd::fileio::encode_all(doc(s), "ai", p).unwrap().warnings; + let (mut masked, _) = open(&framed()); + masked.execute(COLOR_MASK, &hide_white()).unwrap(); + let (mut cleared, _) = open(&framed()); + cleared.execute(super::super::REMOVE_BACKGROUND, &hide_white()).unwrap(); + for (p, said) in [(json!({"aiVersion": 7}), "are flattened"), (json!({"aiVersion": 7, "transparentImages": "omit"}), "are left out")] { + let w = warnings(&masked, &p); + assert!(w.iter().any(|w| w.contains("transparent images") && w.contains(said)), "{p}: {w:?}"); + assert_eq!(w, warnings(&cleared, &p), "{p}"); + } +} + +/// A pattern whose tile holds a masked image counts as one with a see-through image, so outputs +/// without transparency expand it before they flatten. +#[test] +fn a_pattern_tile_with_a_masked_image_counts_as_see_through() { + let href = format!("data:image/png;base64,{}", vectorcraft_format::base64_encode(&framed())); + let svg = format!( + r##""## + ); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "p.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let mut d = doc(&s).clone(); + let see_through = + |d: &Document| crate::cmd::expand::has_transparent_image_patterns(d, &crate::cmd::flatten::see_through_images(d), &|_, _| false); + assert!(!see_through(&d), "an opaque tile"); + let hidden = png(DynamicImage::ImageRgba8(image::RgbaImage::from_fn(12, 10, |x, _| image::Rgba(if x == 0 { [0; 4] } else { RED })))); + d.images.insert("masked".into(), ImageBlob::png(hidden)); + fn image_in(n: &mut vectorcraft_doc::Node) -> Option<&mut vectorcraft_doc::Node> { + if matches!(n.kind, NodeKind::Image(_)) { + return Some(n); + } + n.children_mut()?.iter_mut().find_map(|c| image_in(std::sync::Arc::make_mut(c))) + } + let node = d.patterns.iter_mut().flat_map(|p| p.art.iter_mut()).find_map(|a| image_in(std::sync::Arc::make_mut(a))).unwrap(); + let NodeKind::Image(im) = &node.kind else { panic!("the tile's image") }; + let from = im.key.clone(); + node.extras_mut().color_mask = Some(Box::new(ColorMask { + key: "masked".into(), + from, + background: MaskBackground::Colors, + colors: vec![[255, 255, 255]], + contiguous: false, + measure: vectorcraft_doc::color_mask::MaskMatch::Distance, + tolerance: 0.0, + softness: 0.0, + })); + assert!(see_through(&d), "the tile shows its masked pixels"); } /// SVG writes a colorized gray image with a mask as the coverage of its masked pixels, shared by diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1d39e2b4e4..1e668ac09e 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -834,8 +834,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, colorize: &dyn Fn(&Document, &Node) -> bool, on: bool, levels: u32) -> bool { @@ -844,7 +844,9 @@ fn transparent_image(d: &Document, n: &Node, keys: &BTreeSet, 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)) } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index a197363b23..5e32355e32 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -182,7 +182,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. @@ -194,7 +195,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))) diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index c1e239f57c..42ca9e5a1d 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -153,30 +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, images: &BTreeMap, keys: &mut BTreeSet) { 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; } - // A colorized image with a color mask is separated as it shows: its masked pixels. - let mask = if colorized(node) { node.extras_if_any().and_then(|e| e.color_mask.take()) } else { None }; - if colorized(node) - && let NodeKind::Image(im) = &mut node.kind - { - if let Some(m) = mask.filter(|m| m.made_for(im) && images.contains_key(&m.key)) { - im.key = m.key; + 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); } - keys.insert(im.key.clone()); - im.key = colorized_key(&im.key); } if let Some(ch) = node.children_mut() { for c in ch.iter_mut() { diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 4f2033609b..60d7a9bd67 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -202,6 +202,42 @@ fn separations_print_a_color_masked_image_as_it_shows() { } } +/// 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. diff --git a/docs/mcp.md b/docs/mcp.md index 6099d41044..7758ae7dc1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9827,11 +9827,9 @@ change in one undo step, and an image that `ids` names twice changes once. An im as it is, and when none changes the command adds no undo step and returns `changed: 0`. `cleared` counts the pixels made fully clear in the images that changed. The command refuses, and changes nothing, when an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance while it is -worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), every pixel of an -image would be cleared, or a grayscale image colorized with a process color would get an alpha channel (a colorize -color on a grayscale image with transparency is a spot color, black or white). Remove Background… opens the command's -dialog with Preview off; with Preview on, the canvas shows the result for the values in the dialog, and Cancel puts the -image back. While a dialog previews the command, +worked on (2 GiB, or 512 MiB in the web app, counted from the file's header before it is decoded), or every pixel of an +image would be cleared. Remove Background… opens the command's dialog with Preview off; with Preview on, the canvas +shows the result for the values in the dialog, and Cancel puts the image back. While a dialog previews the command, the session keeps the decoded pixels of the images it changes (at most 128 MiB, or 32 MiB in the web app), so the next preview step does not decode them again; it releases them when the dialog closes with OK or Cancel. A run that is not a preview step does not keep them. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9672c6e9b1..4c224079a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1999,11 +1999,11 @@ A VectorCraft addition (it replaces a script some users ran), so no area score m ## Image Tools add-on: Remove Background (2026-11-11) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance, results with no pixel left and grayscale images colorized with a process color are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). +A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Remove Background… (`addon.image.removeBackground`) makes the background of the selected images transparent in their own pixels, in the app with no service or model. The background is the most common edge color (nothing is cleared when the edges hold at least as many clear pixels as pixels of that color), the average of the corners that are not clear, or picked colors. Contiguous (on by default) clears it only where it reaches in from an edge; off, it is cleared everywhere. Tolerance and Softness (0 to 100, 10 by default, VectorCraft's own values) set how near a color is cleared and the band over which pixels fade out, measured as RGB distance or, with Match set to Each Channel, as the largest difference on one channel. The run is one undo step, and none is recorded when no pixel changes. The image stays an image: a PNG with alpha at its own bit depth, gray or color as it was, with its color profile and resolution, and a linked image is embedded. CMYK images, linked images that show their preview, images that would need more than the decode allowance and results with no pixel left are refused. The dialog's Preview works as Release Type on a Path's does; its preview steps decode an image once, and the decoded pixels are released when the dialog closes. Tests: `cmd::addons::image::tests` in `vectorcraft-engine` (edge, corner and picked colors, contiguous on and off, a second run, tolerance and softness by distance and by channel, 16-bit and gray images, the color profile and resolution kept, an image read at another size, linked images, refusals and bad params, and decoded pixels reused across preview steps and released at OK and Cancel), `addons::image::tests::remove_background_previews_cancels_and_runs_on_ok` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Image Tools add-on. Since 2026-11-12 a selected image also offers Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar, where the reference app offers it (); a click runs it at once with the defaults, as one undo step (tests `canvas::tests::the_task_bar_removes_a_selected_images_background` and `addons::image::tests::properties_and_the_control_bar_remove_the_background_at_once`). ## Image Tools add-on: Color Mask (2026-11-12) -A VectorCraft addition, so no area score moves. Object › Plug-ins › Image Tools › Color Mask… (`addon.image.colorMask`) hides colors of the selected images while their pixels stay as they are. It matches colors as Remove Background does (edge, corner or picked colors, Contiguous, Match, Tolerance and Softness, VectorCraft's own values). The mask's settings and the masked pixels are kept with the image object and saved with the document. The canvas (clip groups and white-shape opacity masks included), printing and separations, PDF (PDF/X-1a flattens the hidden pixels), SVG, EPS, PNG, DXF, EMF, WMF, the legacy .ai, the .ai editing data, copy and paste, and placing a document show the masked pixels. A linked image stays linked, and outputs that would refer to its file write the masked pixels instead. A masked grayscale image takes only a spot, black or white colorize color. The dialog opens on the image's mask settings. Remove Color Mask takes the mask off, and Apply Color Mask makes it part of the image's pixels. Each is one undo step, and none is recorded when nothing changes. When the image's own pixels are replaced (a link updated or read again on open, the image cropped or recolored), the mask is made again for the new pixels. A click anywhere in the image's frame selects it. Tests: `cmd::addons::image::mask::tests` in `vectorcraft-engine` (hidden colors drawn, pixels kept, edit, report, remove and apply, the outputs listed, save and open, copy and paste, placing a document, a linked image, cropped and replaced pixels, colorize colors, refusals), `color_mask::tests` and `placed_document::tests` in `vectorcraft-doc`, the separations and CMYK soft-mask tests in `vectorcraft-pdf`, and `addons::image::tests::color_mask_previews_keeps_its_settings_and_comes_off` in `vectorcraft-ui-egui`. Commands: `docs/mcp.md` › Image Tools add-on › Color Mask. +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 takes only a spot, black or white colorize color. The dialog opens on the image's mask settings. Remove Color Mask takes the mask off, and Apply Color Mask makes it part of the image's pixels. Each is one undo step, and none is recorded when nothing changes. When the image's own pixels are replaced (a link updated or read again on open, the image cropped or recolored), the mask is made again for the new pixels. A click anywhere in the image's frame selects it. Tests: `cmd::addons::image::mask::tests` in `vectorcraft-engine` (hidden colors drawn, pixels kept, edit, report, remove and apply, the outputs listed, save and open, copy and paste, placing a document, a linked image, cropped and replaced pixels, colorize colors, refusals), `color_mask::tests` and `placed_document::tests` in `vectorcraft-doc`, the separations and CMYK soft-mask tests in `vectorcraft-pdf`, and `addons::image::tests::color_mask_previews_keeps_its_settings_and_comes_off` in `vectorcraft-ui-egui`. Commands: `docs/mcp.md` › Image Tools add-on › Color Mask. ## Color Effects add-on: Find Nearest Swatch (2026-11-01) From 2911958414c8e8470c88c0d81b1207f881d0c5b0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:09:30 -0800 Subject: [PATCH 03/22] Image Tools: live color adjustments and envelope warps keep a color mask - A live color adjustment recolors a masked image's masked pixels, on the canvas and in outputs that apply the adjustment, so the colors the mask hides stay hidden. Before, the adjustment recolored the image's own pixels and the hidden colors showed again. - An envelope warp gives each piece of a masked image the image's color mask, so the warped image shows its masked pixels. Tests: a_live_color_adjustment_keeps_the_hidden_colors_hidden and an_envelope_warp_draws_the_masked_pixels. --- crates/doc/src/live/expand.rs | 4 +- crates/effects/src/adjust.rs | 34 ++++++++++++++++ .../engine/src/cmd/addons/image/mask/tests.rs | 40 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 8675dcc011..3b4b14221c 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -182,7 +182,7 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { /// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map /// that takes the triangle where the warp takes its corners (with Preserve Shape Using /// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing). -/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no +/// Each piece keeps the image's color mask. Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no /// seams show. fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let k = w.cells.max(1); @@ -217,6 +217,8 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { let shape = PathData::single(SubPath::polyline(&tri, true)); let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() }))); image.extras_mut().pixelated = !w.options.anti_alias; + // Each piece shows what the image shows: its color mask's masked pixels. + image.extras_mut().color_mask = n.extras().color_mask.clone(); if w.options.preserve_shape == PreserveShape::Transparency { // Preserve Shape Using Transparency: the triangle is the piece's alpha (a // white opacity mask) instead of its clipping path. diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index c345136255..06050df6e2 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -462,11 +462,45 @@ 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); 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. +fn show_color_masks(n: &mut Node) { + 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 { + 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)); + } + } +} + /// [`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. diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index 6f6b454031..35fbf950f4 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -60,8 +60,16 @@ fn undo_len(s: &Session) -> usize { /// The straight RGBA of pixel (`x`, `y`) of image `id` as `d` draws it, on nothing. fn drawn(d: &Document, id: NodeId, x: u32, y: u32) -> [u8; 4] { - let im = image_of(d, id); - let at = im.xf * vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5); + drawn_at(d, pixel_at(d, id, x, y)) +} + +/// Where the center of pixel (`x`, `y`) of image `id` is in `d`. +fn pixel_at(d: &Document, id: NodeId, x: u32, y: u32) -> vectorcraft_geom::Point { + image_of(d, id).xf * vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5) +} + +/// The straight RGBA that `d` draws at `at`, on nothing. +fn drawn_at(d: &Document, at: vectorcraft_geom::Point) -> [u8; 4] { let r = vectorcraft_render::Renderer::new().render_region(d, Rect::new(at.x - 0.5, at.y - 0.5, at.x + 0.5, at.y + 0.5), 1.0, false); let px = r.to_straight(); [px[0], px[1], px[2], px[3]] @@ -621,6 +629,34 @@ fn legacy_ai_flattens_a_masked_image_as_a_see_through_one() { } } +/// A live color adjustment recolors the masked pixels too: the hidden colors stay hidden on the +/// canvas and in outputs that apply the adjustment. +#[test] +fn a_live_color_adjustment_keeps_the_hidden_colors_hidden() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + let d = doc(&s); + assert_eq!(drawn(d, id, 1, 1)[3], 0, "the hidden white"); + let block = drawn(d, id, 4, 4); + assert!(block[3] == 255 && block != RED, "the red block, recolored: {block:?}"); + let r = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let px = svg_image(r["text"].as_str().unwrap()).to_rgba8(); + assert_eq!(px.get_pixel(1, 1).0[3], 0, "SVG: the hidden white"); + assert!(px.get_pixel(4, 4).0[3] == 255 && px.get_pixel(4, 4).0 != RED, "SVG: the red block, recolored"); +} + +/// An envelope warp draws the image's masked pixels in its pieces. +#[test] +fn an_envelope_warp_draws_the_masked_pixels() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let (white, red) = (pixel_at(doc(&s), id, 1, 1), pixel_at(doc(&s), id, 4, 4)); + s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 0})).unwrap(); + assert_eq!(drawn_at(doc(&s), white)[3], 0, "the hidden white"); + assert!(drawn_at(doc(&s), red)[3] > 0, "the red block"); +} + /// A pattern whose tile holds a masked image counts as one with a see-through image, so outputs /// without transparency expand it before they flatten. #[test] From 63a36fec51fcb4a06fe914a0770cd5471df941a6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:26:48 -0800 Subject: [PATCH 04/22] Image Tools: geometry effects keep a color mask An image with a geometry effect (Transform, for example) is drawn from a copy of the image, and that copy now keeps the image's color mask. The canvas and outputs show the masked pixels, and Expand Appearance keeps the mask on the image it makes. Test: a_transform_effect_keeps_the_mask. --- crates/effects/src/reshape.rs | 6 +++++- .../engine/src/cmd/addons/image/mask/tests.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index c629cc10ca..8da6a075b4 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -548,7 +548,11 @@ fn as_art_opts( let mut art = match &n.kind { 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 image = Node::new(n.id, n.kind.clone()); + let mut image = Node::new(n.id, n.kind.clone()); + // The image keeps its color mask, so it shows its masked pixels. + if let Some(m) = &n.extras().color_mask { + image.extras_mut().color_mask = Some(m.clone()); + } let clipped = || { let mut clip = Node::path(n.id, frame.clone(), Appearance::default()); if let NodeKind::Path { clipping, .. } = &mut clip.kind { diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index 35fbf950f4..3e1999888a 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -657,6 +657,23 @@ fn an_envelope_warp_draws_the_masked_pixels() { assert!(drawn_at(doc(&s), red)[3] > 0, "the red block"); } +/// A geometry effect draws the image's masked pixels, and Expand Appearance keeps the mask on the +/// image it makes. +#[test] +fn a_transform_effect_keeps_the_mask() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + let moved = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x + 30.0, p.y); + let (white, red) = (moved(pixel_at(doc(&s), id, 1, 1)), moved(pixel_at(doc(&s), id, 4, 4))); + s.execute("effect.apply", &json!({"ids": [id.0], "effect": "distort.transform", "params": {"moveH": 30}})).unwrap(); + assert_eq!(drawn_at(doc(&s), white)[3], 0, "the hidden white"); + assert_eq!(drawn_at(doc(&s), red), RED, "the red block"); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let mut masks = vec![]; + doc(&s).walk(|n| masks.extend(Document::color_mask_of(n).cloned())); + assert_eq!(masks.len(), 1, "the expanded image keeps its mask"); +} + /// A pattern whose tile holds a masked image counts as one with a see-through image, so outputs /// without transparency expand it before they flatten. #[test] From 9d6188c47ca2fd483a24cd1612e7aa0cdf588983 Mon Sep 17 00:00:00 2001 From: jmverges Date: Thu, 12 Nov 2026 14:59:22 +0000 Subject: [PATCH 05/22] .ai import: type keeps the manual kerning of its text document (#6899) The text document reader never read a story's manual kerning, so type kerned by hand in a .ai or EPS editing copy came in with automatic kerning on every pair and drifted from the file's page from each kerned pair on. Envelopes of such type drew apart from their drawn art and kept it. Key 8 of a story's text holds the kerning as runs of characters (key 1), each with a value in thousandths of an em (key 0 of key 0) or empty where the automatic kerning applies. Each character with a value now takes it as its Kerning, the space after it, and the style runs split where the value changes. A value that can't be read keeps the automatic kerning, a run whose length can't be read ends the runs there, and past 65,536 runs the characters keep the automatic kerning. - ate.rs: Story reads the runs (manual_kerning) and node() splits its text runs at them (Kerns), with a unit test over values, style-run boundaries, hostile values and the laid-out gap, and one that pins the limit of runs. - tests_native.rs: an imported story with kerned and automatic runs. - docs/file-format-parity.md: status line, mapping line, revision row. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ate.rs | 150 ++++++++++++++++++++++++-- crates/eps/src/import/tests_native.rs | 19 ++++ docs/file-format-parity.md | 5 +- 3 files changed, 165 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 194c0ff1e2..8bcb2a93c4 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -244,6 +244,10 @@ fn read(bytes: &[u8]) -> Option { const MAX_SHEETS: usize = 4096; const MAX_SHEETS_BASED_ON: usize = 16; +/// Most runs of manual kerning read of one story; the characters past them keep the automatic +/// kerning. +const MAX_KERN_RUNS: usize = 1 << 16; + /// Most frames read of one story, and most path segments of a frame. pub(crate) const MAX_FRAMES: usize = 4096; pub(crate) const MAX_SEGMENTS: usize = 1 << 16; @@ -428,6 +432,10 @@ pub(super) struct Story { paras: Vec<(usize, Para)>, /// `(characters, style)` in order. runs: Vec<(usize, Style)>, + /// `(characters, manual kerning)` in order: thousandths of an em after each of those characters + /// (key 8 of the story's text, a run's value its key 0), `None` where the automatic kerning + /// applies. Empty when the story has none. + kerns: Vec<(usize, Option)>, /// Type on a path: the spacing its first frame's layout implies ([`path_spacing`]). path_spacing: f64, /// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to @@ -655,12 +663,13 @@ impl Texts { if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); } + let kerns = manual_kerning(s.get("0")?.get("8")); let mut budget = MAX_STORY_TABS; let mut tabs_left_out = false; for (_, p) in &mut paras { tabs_left_out |= p.tabs_truncated | spend_tabs(&mut p.tabs, &mut budget); } - Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() }) + Some(Story { text, anchor, lines, frames, paras, runs, kerns, path_spacing, vertical, tabs_left_out: tabs_left_out.into() }) } /// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based @@ -1111,19 +1120,16 @@ impl Story { let scale = scale * art_scale(to_doc); let chars: Vec = text.chars().collect(); let mut runs = vec![]; + let mut kerns = Kerns::new(&self.kerns); let mut from = 0usize; for (n, st) in &self.runs { let to = from.saturating_add(*n).min(chars.len()); - let piece: String = chars.get(from..to).unwrap_or_default().iter().collect(); + kerns.split(&chars, from, to, &char_style(st, scale), &mut runs); from = to; - if piece.is_empty() { - continue; - } - runs.push(TextRun::new(piece, char_style(st, scale))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale))); + kerns.split(&chars, from, chars.len(), &char_style(last, scale), &mut runs); } let first = self.runs.first().map(|r| char_style(&r.1, scale))?; let mut t = TextObject::point(Point::ZERO, "", first); @@ -1259,6 +1265,64 @@ impl Story { } } +/// The manual kerning of a story (`list`: key 8 of its text): each run's characters and value +/// (thousandths of an em), `None` for a run without one (the automatic kerning) or with a value +/// that can't be read. Neighbouring runs of one value are one run. Empty without the key, and past +/// [`MAX_KERN_RUNS`] runs the characters keep the automatic kerning. +fn manual_kerning(list: Option<&Val>) -> Vec<(usize, Option)> { + let mut kerns: Vec<(usize, Option)> = vec![]; + for run in list.and_then(|k| k.get("0")).map(Val::list).unwrap_or_default().iter().take(MAX_KERN_RUNS) { + let Some(n) = run.get("1").and_then(Val::num).filter(|n| *n >= 0.0 && n.fract() == 0.0) else { break }; + let value = run.get("0").and_then(|v| v.get("0")).and_then(Val::num).filter(|v| v.abs() < 1e5); + match kerns.last_mut() { + Some((count, last)) if *last == value => *count = count.saturating_add(n as usize), + _ => kerns.push((n as usize, value)), + } + } + if kerns.iter().all(|(_, v)| v.is_none()) { + kerns.clear(); + } + kerns +} + +/// A walk along a story's manual kerning runs ([`Story::kerns`]), as its style runs are made into +/// text runs. +struct Kerns<'a> { + runs: &'a [(usize, Option)], + /// The run at the walk and the character it starts at. + at: usize, + start: usize, +} + +impl<'a> Kerns<'a> { + fn new(runs: &'a [(usize, Option)]) -> Self { + Self { runs, at: 0, start: 0 } + } + + /// Push the characters `from..to` of `chars` (`from` no earlier than any before) onto `out`, + /// in style `style`, as a text run for each manual kerning they have. + fn split(&mut self, chars: &[char], from: usize, to: usize, style: &CharStyle, out: &mut Vec) { + let mut at = from; + while at < to { + while let Some((n, _)) = self.runs.get(self.at) + && self.start.saturating_add(*n) <= at + { + self.start = self.start.saturating_add(*n); + self.at += 1; + } + let (end, kerning) = match self.runs.get(self.at) { + Some((n, kerning)) => (self.start.saturating_add(*n).min(to), *kerning), + None => (to, None), + }; + let piece: String = chars.get(at..end).unwrap_or_default().iter().collect(); + if !piece.is_empty() { + out.push(TextRun::new(piece, CharStyle { kerning, ..style.clone() })); + } + at = end; + } + } +} + /// The paragraph style of paragraph attributes `p`, its lengths (indents, spacing, the /// hyphenation zone) scaled by `scale`, as sizes are. fn para_style(p: &Para, scale: f64) -> ParaStyle { @@ -1638,6 +1702,77 @@ mod tests { assert_eq!(stroke(b"/5 1 /6 << /57 false >>"), (LineCap::Butt, LineJoin::Miter, 10.0), "no stroke"); } + /// Key 8 of a story's text is its manual kerning: runs of characters (key 1), each with its + /// value (key 0 of key 0, thousandths of an em) or empty where the automatic kerning applies. + /// Each character with a value takes it as the space after it, across the story's style runs; + /// a value that can't be read keeps the automatic kerning, and a run whose length can't be read + /// ends the reading there. In users' files a heading's first pair is kerned by -140 and the + /// letters of a line by 20 to 220, and the page draws those gaps that much tighter or wider. + #[test] + fn a_story_keeps_its_manual_kerning() { + // "Wave" at 20 points and a Horizontal Scale of 50%, then " tea" at 10 points. + let styles: &[u8] = b"/6 << /0 [ << /0 << /0 << /6 << /1 20.0 /6 0.5 >> >> >> /1 4 >> << /0 << /0 << /6 << /1 10.0 >> >> >> /1 5 >> ] >>"; + let texts = |kerns: &[u8]| Texts { + stories: vec![read(&[b"/0 << /0 (Wave tea\r) ", styles, b" ", kerns, b" >> /1 << /0 [ << /0 0 >> ] >>"].concat()).unwrap()], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![read(b"/0 << /0 [ 0 0 ] /2 << /0 0 >> >>").unwrap()], + }; + let text = |kerns: &[u8]| -> TextObject { + let node = texts(kerns).story(0).unwrap().node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("type") }; + *t + }; + let runs = |kerns: &[u8]| -> Vec<(String, f64, Option)> { + text(kerns).runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.kerning)).collect() + }; + let run = |text: &str, size: f64, kerning: Option| (text.to_string(), size, kerning); + // "W" -140, "av" none, "e t" 80 across the style runs, "e" a value that can't be read, "a" none. + let kerned: &[u8] = b"/8 << /0 [ << /0 << /0 -140 >> /1 1 >> << /0 << >> /1 2 >> << /0 << /0 80 >> /1 3 >> \ + << /0 << /0 (x) >> /1 1 >> << /0 << >> /1 2 >> ] >>"; + assert_eq!( + runs(kerned), + [run("W", 20.0, Some(-140.0)), run("av", 20.0, None), run("e", 20.0, Some(80.0)), run(" t", 10.0, Some(80.0)), run("ea", 10.0, None)] + ); + // Without the key, or with no value in it, the style runs stay as they are. + let plain = [run("Wave", 20.0, None), run(" tea", 10.0, None)]; + assert_eq!(runs(b""), plain); + assert_eq!(runs(b"/8 << /0 [ << /0 << >> /1 9 >> ] >>"), plain); + // Values out of range keep the automatic kerning, and a length that can't be read ends the runs. + let out_of_range: &[u8] = b"/8 << /0 [ << /0 << /0 1e9 >> /1 1 >> << /0 << /0 NaN >> /1 1 >> << /0 << /0 60 >> /1 1 >> ] >>"; + assert_eq!(runs(out_of_range)[..2], [run("Wa", 20.0, None), run("v", 20.0, Some(60.0))]); + for bad in [b"-1".as_slice(), b"1.5", b"(1)", b"NaN"] { + let k = [b"/8 << /0 [ << /0 << /0 60 >> /1 1 >> << /0 << /0 60 >> /1 ".as_slice(), bad, b" >> << /0 << /0 60 >> /1 2 >> ] >>"].concat(); + assert_eq!(runs(&k), [run("W", 20.0, Some(60.0)), run("ave", 20.0, None), run(" tea", 10.0, None)], "{}", String::from_utf8_lossy(bad)); + } + // Laid out, the space after "W" is 140 thousandths of the em tighter than with a value of + // 0, of the em as the run's Horizontal Scale (50%) draws it. + let db = vectorcraft_text::FontDb::global(); + let gap = |t: &TextObject| { + let laid = vectorcraft_text::layout(db, t); + laid.glyphs[1].origin.x - laid.glyphs[0].origin.x + }; + let (with, zero) = (gap(&text(kerned)), gap(&text(b"/8 << /0 [ << /0 << /0 0 >> /1 1 >> ] >>"))); + assert!((zero - with - 0.14 * 20.0 * 0.5).abs() < 1e-6, "{with} {zero}"); + } + + /// Past [`MAX_KERN_RUNS`] runs of manual kerning the characters keep the automatic kerning, so a + /// story makes no more text runs of its kerning than that. + #[test] + fn manual_kerning_reads_at_most_its_limit_of_runs() { + let dict = |entries: Vec<(&str, Val)>| Val::Dict(entries.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); + // One character each, the values alternating, so no two neighbouring runs are one. + let run = |i: usize| dict(vec![("0", dict(vec![("0", Val::Num(if i.is_multiple_of(2) { 10.0 } else { 20.0 }))])), ("1", Val::Num(1.0))]); + let list = dict(vec![("0", Val::List((0..MAX_KERN_RUNS + 2).map(run).collect()))]); + let kerns = manual_kerning(Some(&list)); + assert_eq!(kerns.len(), MAX_KERN_RUNS); + assert_eq!(kerns.iter().map(|(n, _)| n).sum::(), MAX_KERN_RUNS); + assert_eq!(kerns.last(), Some(&(1, Some(20.0)))); + } + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one /// that doesn't say, goes by its letters. @@ -1913,6 +2048,7 @@ mod tests { standard_vertical_roman: false, }, )], + kerns: vec![], path_spacing: 0.0, vertical: false, tabs_left_out: false.into(), diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 7353c5c190..1143ba13a9 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3366,6 +3366,25 @@ fn stroked_type_keeps_its_cap_join_and_miter_limit() { ); } +#[test] +fn type_keeps_its_manual_kerning() { + // GUARDS: the text document's manual kerning (key 8 of a story's text: runs of characters, each + // with its value or none) wasn't read, so type the file kerns by hand came in spaced by the + // font alone and drifted from the page from each kerned pair on. "Wave": "W" kerned by -140, + // "av" automatic, "e" by 60. + let doc = text_document(&[("Wave\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let run = "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 5 >>"; + assert!(doc.contains(run)); + let kerns = "/8 << /0 [ << /0 << /0 -140 >> /1 1 >> << /0 << >> /1 2 >> << /0 << /0 60 >> /1 1 >> << /0 << >> /1 1 >> ] >>"; + let doc = doc.replacen(run, &format!("{run} ] >> {kerns}"), 1).replacen(&format!("{kerns} ] >> >>"), &format!("{kerns} >>"), 1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let runs: Vec<(String, Option)> = texts[0].runs.iter().map(|r| (r.text.clone(), r.style.kerning)).collect(); + assert_eq!(runs, [("W".to_string(), Some(-140.0)), ("av".to_string(), None), ("e".to_string(), Some(60.0))]); +} + #[test] fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dc11b940d0..de45763749 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: type keeps the manual kerning of its text document, #6899; newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -56,7 +56,7 @@ private editing data. VectorCraft: own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. Type from the text document keeps auto leading as auto, each line taking the paragraph's share (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), - and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Area type of one + and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type keeps its manual kerning (key 8 of a story's text, thousandths of an em after a character). Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -343,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import: type from the text document keeps its manual kerning (#6899). Key 8 of a story's text holds it as runs of characters (key 1), each with a value (key 0 of key 0, in thousandths of an em) or empty where the automatic kerning applies; a character with a value takes it as its Kerning, the space between it and the next character ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), of the em as its Horizontal Scale draws it, as tracking. It wasn't read, so type kerned by hand came in spaced by the font alone and drifted from the page from each kerned pair on, and envelopes of such type drew apart from their drawn art and kept it. In users' files, used locally and never committed, 48 stories in 17 of 224 files have it, and the drawn art of envelopes of such type sets each kerned gap wider or narrower by exactly that value (a heading's first pair at -140, the letters of a line at 20 to 220). Measured against each file's own page, 14 of those files draw closer (in 4 the pixels that differ go from 2.7–4.7% to under 0.6%) and 2 a little further, both where an envelope of type now opens live in place of its drawn art; envelopes of type that open live go from 154 to 159 of 203, none fewer in any file, and two notes that the type differs from the page go. A value that can't be read keeps the automatic kerning, a run whose length can't be read ends the runs there, and past 65,536 runs the characters keep the automatic kerning. `.ai` save still leaves kerning out of the editing data, so saving such a file as `.ai` again now gives the note that attributes of type are left out for type with manual kerning. Tests `ate::tests::a_story_keeps_its_manual_kerning`, `tests_native::type_keeps_its_manual_kerning`; scores unchanged | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | From 00e07698b1c4f091c473c45497ba6298b5584ff0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:59:11 -0800 Subject: [PATCH 06/22] Image Tools: a live color adjustment recolors an image whose masked pixels are missing When the document does not hold a color mask's masked pixels, the image is drawn from its own pixels. A live color adjustment now recolors those pixels and drops the mask on the recolored copy, instead of pointing the image at the missing masked pixels, which drew nothing and left Expand Appearance with an image whose pixels were not in the document. --- crates/effects/src/adjust.rs | 47 ++++++++++++++----- .../engine/src/cmd/addons/image/mask/tests.rs | 22 +++++++++ crates/render/src/fx/renderer.rs | 22 +++++---- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index 06050df6e2..4193b085ef 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -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 { +/// `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 { let mut m: Option = 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,7 +470,7 @@ 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); + show_color_masks(mm, has_image); recolor_node(mm, Reach::ALL, &mut Live { map: &map, swatches, images }); } m @@ -484,23 +492,35 @@ fn holds_recoloured_with_mask(n: &Node) -> bool { /// 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. -fn show_color_masks(n: &mut Node) { +/// 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 { + 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)); + 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. @@ -516,13 +536,16 @@ pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) _ => 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 { 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)] @@ -585,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()); @@ -610,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 = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect(); diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index 3e1999888a..071c82bc7a 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -646,6 +646,28 @@ fn a_live_color_adjustment_keeps_the_hidden_colors_hidden() { assert!(px.get_pixel(4, 4).0[3] == 255 && px.get_pixel(4, 4).0 != RED, "SVG: the red block, recolored"); } +/// An image whose masked pixels are missing from the document is drawn and expanded from its own +/// pixels with a live color adjustment, as it is drawn without one. +#[test] +fn a_live_color_adjustment_recolors_the_image_when_its_masked_pixels_are_missing() { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + let mut d = doc(&s).clone(); + let masked = mask_of(&d, id).unwrap().key; + assert!(d.images.remove(&masked).is_some(), "the masked pixels were in the document"); + assert_eq!(drawn(&d, id, 1, 1), WHITE, "the white shows"); + let block = drawn(&d, id, 4, 4); + assert!(block[3] == 255 && block != RED, "the red block, recolored: {block:?}"); + let node = d.node(id).unwrap().clone(); + let out = vectorcraft_render::effects::adjust_in_document(&mut d, &node, None).unwrap(); + let NodeKind::Image(im) = &out.kind else { panic!("an image") }; + assert_ne!(im.key, image_of(&d, id).key, "the expanded image is recolored"); + assert_eq!(Document::color_mask_of(&out), None, "the expanded image has no mask to make again from the recolored pixels"); + let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(1, 1).0, WHITE, "the expanded image shows the white"); +} + /// An envelope warp draws the image's masked pixels in its pieces. #[test] fn an_envelope_warp_draws_the_masked_pixels() { diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index e9aa8486d8..1e4c39f4eb 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -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); } From dc79bbbe73791bd682329352e621bc072b47f315 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 12:07:59 -0800 Subject: [PATCH 07/22] Image Tools: Color Mask refuses a process color on each gray image it masks Color Mask checked the whole document for a grayscale image colorized with a process color only when the document passed that check before the run. With another such image already in the document, the check was skipped, and a process-colorized gray image could take masked pixels with transparency. Each image whose mask changes is now checked on the pixels the new mask shows, made from the image's own pixels, so an image elsewhere in the document no longer affects the result. A mask that hides nothing keeps the image's own pixels and is still set. When new pixels replace a masked gray image's own pixels under a process color, the mask made for them hides nothing, and Apply Color Mask refuses them. The check shares its colorize predicate with paint's check_colorized. --- crates/engine/src/cmd/addons/image/mask.rs | 49 ++++--- .../engine/src/cmd/addons/image/mask/tests.rs | 124 +++++++++++++++++- crates/engine/src/cmd/paint.rs | 24 +++- docs/mcp.md | 12 +- 4 files changed, 182 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/addons/image/mask.rs b/crates/engine/src/cmd/addons/image/mask.rs index d6dc451f4f..786effbd2e 100644 --- a/crates/engine/src/cmd/addons/image/mask.rs +++ b/crates/engine/src/cmd/addons/image/mask.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::color_mask::{ColorMask, MaskBackground, MaskMatch}; use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; -use super::super::super::paint::{check_colorized, check_colorized_kept}; +use super::super::super::paint::{PROCESS_ON_TRANSPARENCY, colorized_with_process}; use super::super::super::{CommandSpec, Result, Session, bad, cmd}; use super::{Background, DecodedImages, MAX_COLORS, Match, Settings, clear, images, targets}; @@ -38,7 +38,7 @@ pub fn specs() -> Vec { "Color Mask…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges) | \"corners\" (the average of the image's four corner pixels that are not clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 hex colors or black, white, red, green and blue, as addon.image.removeBackground takes them; needed with background: colors), contiguous?: bool (true: only matching pixels that reach in from an edge are hidden; false: wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a hidden color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (% as addon.image.removeBackground measures it; a pixel this near a hidden color is hidden; 10), softness?: 0..100 (% past the tolerance over which pixels fade from hidden to shown; 10), ids?: [id…] (default: the selected images)} hide colors of each image without changing its pixels, as one undo step: the image keeps its own pixels, and the mask (these settings, and the image's pixels with the hidden colors made clear, made as Remove Background makes them) is kept with the image and saved with the document; the canvas, printing and separations, PDF, SVG, EPS, raster formats, DXF, EMF, WMF, the legacy .ai and the .ai editing data, copy and paste, and placing a document show the masked pixels, and a click on any part of the image's frame selects it (the Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels); setting the mask again replaces it, and an image whose mask does not change is left as it is (no undo step when none changes); when the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, a linked file read again on open), the mask is made again for the new pixels; an object in `ids` that is not an image, a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a mask that would hide every pixel, and a grayscale image colorized with a process color (masked gray pixels have transparency, which only a spot color, black or white colorizes) are refused. With none of background, colors, contiguous, match, tolerance and softness given, nothing changes and the mask settings of the first image (or the defaults when it has no mask) are returned → {changed: the images changed, ids, hidden: the pixels made fully clear in them} or {background, colors, contiguous, match, tolerance, softness, masked}", + "{background?: \"edges\" (default: the most common color along the image's edges) | \"corners\" (the average of the image's four corner pixels that are not clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 hex colors or black, white, red, green and blue, as addon.image.removeBackground takes them; needed with background: colors), contiguous?: bool (true: only matching pixels that reach in from an edge are hidden; false: wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a hidden color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (% as addon.image.removeBackground measures it; a pixel this near a hidden color is hidden; 10), softness?: 0..100 (% past the tolerance over which pixels fade from hidden to shown; 10), ids?: [id…] (default: the selected images)} hide colors of each image without changing its pixels, as one undo step: the image keeps its own pixels, and the mask (these settings, and the image's pixels with the hidden colors made clear, made as Remove Background makes them) is kept with the image and saved with the document; the canvas, printing and separations, PDF, SVG, EPS, raster formats, DXF, EMF, WMF, the legacy .ai and the .ai editing data, copy and paste, and placing a document show the masked pixels, and a click on any part of the image's frame selects it (the Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels); setting the mask again replaces it, and an image whose mask does not change is left as it is (no undo step when none changes); when the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, a linked file read again on open), the mask is made again for the new pixels; an object in `ids` that is not an image, a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a mask that would hide every pixel, and a mask that would hide pixels of a grayscale image colorized with a process color (masked gray pixels have transparency, which only a spot color, black or white colorizes; each image is checked on the pixels its new mask shows) are refused. With none of background, colors, contiguous, match, tolerance and softness given, nothing changes and the mask settings of the first image (or the defaults when it has no mask) are returned → {changed: the images changed, ids, hidden: the pixels made fully clear in them} or {background, colors, contiguous, match, tolerance, softness, masked}", super::has_image, color_mask ), @@ -56,7 +56,7 @@ pub fn specs() -> Vec { "Apply Color Mask", ["Object", "Plug-ins", "Image Tools"], None, - "{ids?: [id…] (default: the selected images)} make each image's color mask part of its pixels, as one undo step: the masked pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is taken off; a mask that hides nothing is only taken off (the image keeps its pixels and its link); images without a mask are left as they are (no undo step when none has one), and an object in `ids` that is not an image is refused → {changed, ids}", + "{ids?: [id…] (default: the selected images)} make each image's color mask part of its pixels, as one undo step: the masked pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is taken off; a mask that hides nothing is only taken off (the image keeps its pixels and its link); images without a mask are left as they are (no undo step when none has one), and an object in `ids` that is not an image, and a mask made again for new pixels that would give a grayscale image colorized with a process color an alpha channel, are refused → {changed, ids}", has_color_mask, apply_color_mask ), @@ -128,7 +128,6 @@ fn color_mask(s: &mut Session, p: &Value) -> Result { let ids: Vec = changes.iter().map(|c| c.0.0).collect(); let hidden: u64 = changes.iter().map(|c| c.3).sum(); if !changes.is_empty() { - let colorized_ok = check_colorized(&s.doc()?.doc).is_ok(); s.edit("Color Mask", |d, _| { for (id, mask, blob, _) in changes { if let Some(blob) = blob { @@ -138,9 +137,7 @@ fn color_mask(s: &mut Session, p: &Value) -> Result { n.extras_mut().color_mask = Some(Box::new(mask)); } } - // Masked pixels have an alpha channel, which a grayscale image colorized with a process - // color can't take. - check_colorized_kept(colorized_ok, d).map_err(|e| bad(COLOR_MASK, e)) + Ok(()) })?; } Ok(json!({ "changed": ids.len(), "ids": ids, "hidden": hidden })) @@ -171,12 +168,26 @@ fn mask_changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&m if st.doc.node(id).and_then(Document::color_mask_of) == Some(&mask) { continue; } + // Masked pixels that hide something have an alpha channel, which a grayscale image + // colorized with a process color can't take. The pixels the new mask shows are checked, + // made from the image's own pixels, not the ones an earlier mask shows. + let shown = pixels.as_ref().or_else(|| st.doc.images.get(&mask.key)); + if st.doc.node(id).is_some_and(|n| refused_colorize(&st.doc, n, shown)) { + return Err(bad(COLOR_MASK, PROCESS_ON_TRANSPARENCY)); + } let pixels = pixels.filter(|_| !st.doc.images.contains_key(&mask.key)); out.push((id, mask, pixels, hidden)); } Ok(out) } +/// Whether masked pixels `shown`, made for image node `n`, have transparency that `n`'s colorize +/// color can't take: gray pixels with an alpha channel and a process color +/// ([`colorized_with_process`]). +fn refused_colorize(doc: &Document, n: &Node, shown: Option<&ImageBlob>) -> bool { + shown.and_then(ImageBlob::gray_source).is_some_and(|g| g.alpha) && colorized_with_process(doc, n) +} + /// Color Mask without settings: the first image's mask settings, or the defaults. It reads them /// for images a change would refuse too (CMYK, a linked image at its preview), so the dialog opens. fn report(s: &Session, p: &Value) -> Result { @@ -239,7 +250,11 @@ fn apply_color_mask(s: &mut Session, p: &Value) -> Result { made.push((*id, None)); continue; } - let size = mask_size(st.doc.images.get(&mask.0.key).or(mask.1.as_ref()), im.width, im.height); + let shown = st.doc.images.get(&mask.0.key).or(mask.1.as_ref()); + if refused_colorize(&st.doc, n, shown) { + return Err(bad(APPLY_COLOR_MASK, PROCESS_ON_TRANSPARENCY)); + } + let size = mask_size(shown, im.width, im.height); made.push((*id, Some((mask, size)))); } } @@ -371,11 +386,12 @@ fn stale_mask(doc: &Document, n: &Node) -> bool { if im.pixels.is_none() && !(m.made_for(im) && (m.key == im.key || doc.images.contains_key(&m.key)))) } -/// One [`refresh`] pass: the masks made so far, by the image's pixels and the mask's settings (an -/// image shown by many objects is decoded once), and the masked pixels to add to the document. +/// One [`refresh`] pass: the masks made so far, by the image's pixels, the mask's settings and +/// whether a process color colorizes the image (an image shown by many objects is decoded once), +/// and the masked pixels to add to the document. struct Refresh<'a> { doc: &'a Document, - made: HashMap<(String, String), ColorMask>, + made: HashMap<(String, String, bool), ColorMask>, blobs: Vec<(String, ImageBlob)>, } @@ -399,24 +415,27 @@ impl Refresh<'_> { /// The mask of image node `n` made again for the image's own pixels ([`fresh`]), `None` when /// it is up to date. A mask that can't be made for them (every pixel hidden, CMYK pixels, a - /// linked preview, too large) keeps its settings and hides nothing: the image shows its own - /// pixels, and setting the mask again reports why. + /// linked preview, too large, gray pixels with transparency under a process color) keeps its + /// settings and hides nothing: the image shows its own pixels, and setting the mask again + /// reports why. fn made(&mut self, n: &Node) -> Option { let (NodeKind::Image(im), Some(m)) = (&n.kind, Document::color_mask_of(n)) else { return None }; if !stale_mask(self.doc, n) { return None; } let settings = ColorMask { key: String::new(), from: String::new(), ..m.clone() }; - let at = (im.key.clone(), format!("{settings:?}")); + let at = (im.key.clone(), format!("{settings:?}"), colorized_with_process(self.doc, n)); if let Some(made) = self.made.get(&at) { return Some(made.clone()); } + let hides_nothing = || ColorMask { key: im.key.clone(), from: im.key.clone(), ..m.clone() }; let mask = match fresh(self.doc, n, COLOR_MASK)? { + Ok((mask, blob)) if refused_colorize(self.doc, n, blob.as_ref().or_else(|| self.doc.images.get(&mask.key))) => hides_nothing(), Ok((mask, blob)) => { self.blobs.extend(blob.map(|b| (mask.key.clone(), b))); mask } - Err(_) => ColorMask { key: im.key.clone(), from: im.key.clone(), ..m.clone() }, + Err(_) => hides_nothing(), }; self.made.insert(at, mask.clone()); Some(mask) diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index 071c82bc7a..fb978235d9 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -2,8 +2,9 @@ use std::io::Cursor; use image::DynamicImage; use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::color_mask::{ColorMask, MaskBackground}; -use vectorcraft_doc::{Document, ImageBlob, ImageObject, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::{APPLY_COLOR_MASK, COLOR_MASK, REMOVE_COLOR_MASK}; @@ -406,6 +407,127 @@ fn a_process_color_does_not_colorize_masked_gray_pixels() { assert!(e.contains(REFUSED), "{e}"); } +/// Another image that already has an alpha channel and a process colorize color, as an opened file +/// can hold, neither lets a grayscale image colorized with a process color take a mask nor stops a +/// mask on an image that can take one. +#[test] +fn another_image_colorized_with_transparency_does_not_change_the_refusal() { + const REFUSED: &str = "a process color can't colorize a grayscale image with transparency"; + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_pixel(2, 2, image::LumaA([128, 100]))))); + s.edit("Place", |d, _| { + let key = held.content_key(); + d.images.insert(key.clone(), held.clone()); + let other = d.alloc_id(); + let im = ImageObject { key, width: 2, height: 2, xf: Default::default(), link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(other, NodeKind::Image(Box::new(im))); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + d.insert(d.default_layer(), 0, n)?; + Ok(()) + }) + .unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![id], "only the gray image is selected"); + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains(COLOR_MASK) && e.contains(REFUSED), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps), "with another image refused already, nothing changes"); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + assert_eq!(s.execute(COLOR_MASK, &hide_white()).unwrap()["changed"], 1, "an image colorized with black takes the mask beside the other"); + let m = mask_of(doc(&s), id).unwrap(); + let masked = image::load_from_memory(&doc(&s).images[&m.key].bytes).unwrap().to_luma_alpha8(); + assert_eq!((masked.get_pixel(0, 0).0[1], masked.get_pixel(4, 4).0[1]), (0, 255), "white is hidden and black shows"); +} + +/// The mask is made from the image's own pixels: a grayscale image colorized with a process color +/// is refused even when an earlier mask, as an opened file can hold, shows color pixels. +#[test] +fn a_gray_image_whose_earlier_mask_shows_color_pixels_is_refused() { + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let rgb = ImageBlob::png(framed()); + s.edit("Mask", |d, _| { + let key = rgb.content_key(); + d.images.insert(key.clone(), rgb.clone()); + let from = image_of(d, id).key; + let mask = ColorMask { key, from, ..mask_of_settings() }; + d.node_mut(id).unwrap().extras_mut().color_mask = Some(Box::new(mask)); + Ok(()) + }) + .unwrap(); + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(COLOR_MASK, &hide_white()).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); +} + +/// A mask that hides nothing leaves the image's own pixels without transparency, so a grayscale +/// image colorized with a process color takes it. +#[test] +fn a_mask_that_hides_nothing_on_a_gray_image_colorized_with_a_process_color_is_set() { + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let none = json!({"background": "colors", "colors": "#808080", "contiguous": false, "tolerance": 0, "softness": 0}); + let r = s.execute(COLOR_MASK, &none).unwrap(); + assert_eq!((r["changed"].as_u64(), r["hidden"].as_u64()), (Some(1), Some(0))); + let (m, im) = (mask_of(doc(&s), id).unwrap(), image_of(doc(&s), id)); + assert_eq!(m.key, im.key, "the masked pixels are the image's own"); + assert!(doc(&s).node(id).unwrap().extras().colorize.is_some(), "the image stays colorized"); +} + +/// New gray pixels whose colors a mask hides would take transparency, which a process color can't +/// colorize: the mask made again for them hides nothing, and Apply Color Mask refuses them. +#[test] +fn new_gray_pixels_under_a_process_color_are_not_masked() { + const REFUSED: &str = "a process color can't colorize a grayscale image with transparency"; + let gray = json!({"background": "colors", "colors": "#808080", "contiguous": false, "tolerance": 0, "softness": 0}); + let swapped = + || ImageBlob::png(png(DynamicImage::ImageLuma8(image::GrayImage::from_fn(12, 10, |x, _| image::Luma([if x < 6 { 128 } else { 0 }]))))); + let setup = || { + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.execute(COLOR_MASK, &gray).unwrap()["hidden"], 0, "no pixel is gray yet"); + (s, id) + }; + // Pixels put in place by an edit: the mask is made again for them and hides nothing. + let (mut s, id) = setup(); + s.edit("Swap", |d, _| { + let blob = swapped(); + let key = blob.content_key(); + d.images.insert(key.clone(), blob); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key; + } + Ok(()) + }) + .unwrap(); + let d = doc(&s); + let (im, m) = (image_of(d, id), mask_of(d, id).unwrap()); + assert_eq!((m.from.as_str(), m.key.as_str()), (im.key.as_str(), im.key.as_str()), "the mask hides nothing"); + assert_eq!(crate::cmd::paint::check_colorized(d), Ok(())); + // Pixels put in place without an edit: Apply Color Mask makes the mask again and refuses it. + let (mut s, id) = setup(); + let blob = swapped(); + let key = blob.content_key(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.images.insert(key.clone(), blob); + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.key = key; + } + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(APPLY_COLOR_MASK, &json!({})).unwrap_err().to_string(); + assert!(e.contains(APPLY_COLOR_MASK) && e.contains(REFUSED), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); +} + +/// A mask that hides white, made for another document's image (only its settings are of use). +fn mask_of_settings() -> ColorMask { + let (mut s, id) = open(&framed()); + s.execute(COLOR_MASK, &hide_white()).unwrap(); + mask_of(doc(&s), id).unwrap() +} + /// Images outside the layers (a symbol's art, opacity-mask art) take their masks again too when /// their own pixels are replaced, as Update Link replaces them there. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 48398e1fdd..df388b81ab 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind}; use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target}; use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced}; @@ -720,7 +720,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: } /// The message for a process color on a grayscale image with an alpha channel. -const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; +pub(super) const PROCESS_ON_TRANSPARENCY: &str = + "a process color can't colorize a grayscale image with transparency: use a spot color, black or white"; /// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch /// of `d`, black or white? @@ -739,11 +740,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> { let mut refused = false; let mut visit = |n: &Node| { vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { - if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref()) - && !refused - && !colorizes_transparency(d, paint) - && d.image_blob(&d.shown_image(m, im)).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha) - { + if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) { refused = true; } }) @@ -763,6 +760,19 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// Is `n` an image colorized with a color that [`colorizes_transparency`] refuses? When its pixels +/// are grayscale or 1-bit, they can't have an alpha channel. +pub(super) fn colorized_with_process(d: &Document, n: &Node) -> bool { + matches!(n.kind, NodeKind::Image(_)) && n.extras().colorize.as_deref().is_some_and(|paint| !colorizes_transparency(d, paint)) +} + +/// How the pixels `n` shows are gray, when `n` is a grayscale or 1-bit image colorized with a +/// color that [`colorizes_transparency`] refuses ([`colorized_with_process`]). +pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option { + let NodeKind::Image(im) = &n.kind else { return None }; + if colorized_with_process(d, n) { d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) } else { None } +} + /// Is `e` the refusal of [`check_colorized`]? pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { e.to_string().contains(PROCESS_ON_TRANSPARENCY) diff --git a/docs/mcp.md b/docs/mcp.md index 6d02cf14d1..b4964c10f8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10131,8 +10131,9 @@ profile and resolution). PDF's color key masking hides chosen colors of an image show the masked pixels. A linked image stays linked. An output that refers to linked files instead of embedding them (SVG with `images: "link"`) writes the masked pixels in place of the link. - A grayscale image's masked pixels have an alpha channel. A colorize color on a masked grayscale image follows the rule - for grayscale images with transparency (a spot color, black or white), and Color Mask refuses a grayscale image - colorized with a process color. + for grayscale images with transparency (a spot color, black or white), and Color Mask refuses a mask that hides + pixels of a grayscale image colorized with a process color. Each image is checked on the pixels its new mask shows, + whatever other images in the document hold. - A click anywhere in the image's frame selects it, hidden pixels included, as it does for an image with clear pixels. The Eyedropper, Object Mosaic, Create Gradient Mesh, Crop Image's trim and Image Trace read the image's own pixels, hidden colors included. @@ -10141,7 +10142,8 @@ profile and resolution). PDF's color key masking hides chosen colors of an image `hidden` counts the pixels made fully clear in the images that changed. - When the image's own pixels are replaced later (Update Link, Relink, Crop Image, a recolor, or a linked file read again when the document opens), the mask is made again for the new pixels. When the new pixels can't be masked (CMYK - pixels, missing pixels, or every pixel would be hidden), the mask keeps its settings and hides nothing. + pixels, missing pixels, every pixel would be hidden, or the image is grayscale and colorized with a process color + and the mask would hide some of its pixels), the mask keeps its settings and hides nothing. - The command refuses, and changes nothing, when an object in `ids` is not an image, an image is CMYK, a linked image shows its preview (embed or relink it first), an image would need more than the decode allowance, or the mask would hide every pixel of an image. @@ -10155,7 +10157,9 @@ profile and resolution). PDF's color key masking hides chosen colors of an image the images show their own pixels again. `addon.image.applyColorMask` makes each mask part of its image: the masked pixels become the image's own, embedded as a PNG with an alpha channel (a linked image is embedded), and the mask is taken off, in one undo step. A mask that hides nothing is only taken off, and the image keeps its pixels and its link. -Images without a mask are left as they are, and an object in `ids` that is not an image is refused. Both commands are +Images without a mask are left as they are, and an object in `ids` that is not an image is refused. Apply Color Mask +also refuses, and changes nothing, when a mask made again for new pixels would give a grayscale image colorized with a +process color an alpha channel. Both commands are enabled only when a selected image has a mask. → `{changed, ids}` ```json From 76d61be5664b9b9e5746fa1dfd10246fa4312f31 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 14:14:24 -0800 Subject: [PATCH 08/22] Image Tools: Remove Background checks the pixels it clears for a process colorize color Remove Background refused a grayscale image colorized with a process color by looking at the pixels the image shows. With a color mask whose masked pixels are not gray, as an opened file can hold, the check let the image through, and the gray pixels it cleared took an alpha channel under the process color. The check now reads the image's own pixels, which are the ones cleared. docs/mcp.md and the Color Mask line in docs/target-app-parity.md now say that a mask that hides no pixels leaves a grayscale image free to take a process color. --- crates/engine/src/cmd/addons/image.rs | 9 ++++--- .../engine/src/cmd/addons/image/mask/tests.rs | 26 +++++++++++++++++++ docs/mcp.md | 10 ++++--- docs/target-app-parity.md | 2 +- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index f95caf422e..898c28c707 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -24,7 +24,7 @@ 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. @@ -173,10 +173,11 @@ fn remove_background(s: &mut Session, p: &Value) -> Result { fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { let images = images(s, p, C)?; // The result has an alpha channel, which a grayscale image colorized with a process color - // can't take. + // 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()?; - for (id, ..) in &images { - if st.doc.node(*id).is_some_and(|n| 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)); } } diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index fb978235d9..9faef84edb 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -461,6 +461,32 @@ fn a_gray_image_whose_earlier_mask_shows_color_pixels_is_refused() { assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); } +/// Remove Background clears the image's own pixels: a grayscale image colorized with a process +/// color is refused even when an earlier mask, as an opened file can hold, shows color pixels. +#[test] +fn remove_background_refuses_a_gray_image_whose_earlier_mask_shows_color_pixels() { + let (mut s, id) = open(&gray_framed()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let rgb = ImageBlob::png(framed()); + let key = rgb.content_key(); + s.edit("Mask", |d, _| { + d.images.insert(key.clone(), rgb.clone()); + let from = image_of(d, id).key; + let mask = ColorMask { key: key.clone(), from, ..mask_of_settings() }; + d.node_mut(id).unwrap().extras_mut().color_mask = Some(Box::new(mask)); + Ok(()) + }) + .unwrap(); + let d = doc(&s); + let n = d.node(id).unwrap(); + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + assert_eq!(d.shown_image(n, im).key, key, "the image shows the color pixels"); + let (before, steps) = (doc(&s).clone(), undo_len(&s)); + let e = s.execute(super::super::REMOVE_BACKGROUND, &hide_white()).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((doc(&s), undo_len(&s)), (&before, steps)); +} + /// A mask that hides nothing leaves the image's own pixels without transparency, so a grayscale /// image colorized with a process color takes it. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 524af3a661..bb6cd3b7d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10296,10 +10296,12 @@ profile and resolution). PDF's color key masking hides chosen colors of an image EPS, the raster formats, DXF, EMF, WMF, the legacy .ai, the .ai editing data, copy and paste, and placing a document show the masked pixels. A linked image stays linked. An output that refers to linked files instead of embedding them (SVG with `images: "link"`) writes the masked pixels in place of the link. -- A grayscale image's masked pixels have an alpha channel. A colorize color on a masked grayscale image follows the rule - for grayscale images with transparency (a spot color, black or white), and Color Mask refuses a mask that hides - pixels of a grayscale image colorized with a process color. Each image is checked on the pixels its new mask shows, - whatever other images in the document hold. +- A 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8bd6b79020..1d7f8603ef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2047,7 +2047,7 @@ A VectorCraft addition, so no area score moves. Object › Plug-ins › Image To ## 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 takes only a spot, black or white colorize color. The dialog opens on the image's mask settings. Remove Color Mask takes the mask off, and Apply Color Mask makes it part of the image's pixels. Each is one undo step, and none is recorded when nothing changes. When the image's own pixels are replaced (a link updated or read again on open, the image cropped or recolored), the mask is made again for the new pixels. A click anywhere in the image's frame selects it. Tests: `cmd::addons::image::mask::tests` in `vectorcraft-engine` (hidden colors drawn, pixels kept, edit, report, remove and apply, the outputs listed, save and open, copy and paste, placing a document, a linked image, cropped and replaced pixels, colorize colors, refusals), `color_mask::tests` and `placed_document::tests` in `vectorcraft-doc`, the separations and CMYK soft-mask tests in `vectorcraft-pdf`, and `addons::image::tests::color_mask_previews_keeps_its_settings_and_comes_off` in `vectorcraft-ui-egui`. Commands: `docs/mcp.md` › Image Tools add-on › Color Mask. +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) From 6c280fa05a7f32f6e1ca04828bc72f5c34d20321 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 15:24:18 -0800 Subject: [PATCH 09/22] Control bar: dragging the gripper away from both docked edges floats the bar Dragging the Control bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks the bar at that edge, as before. Released farther from both edges, the bar now floats over the window under the pointer, 800 points wide or as wide as the window, without taking room from the canvas. A floating bar's gripper moves it or docks it again, and its panel menu keeps Dock to Top and Dock to Bottom, neither checked while it floats. - window.control.dock takes edge "float"; a floating bar floats where it last floated, first at 60, 80 in window points. - Escape cancels a gripper drag, and a release outside the window leaves the bar where it was. In a window too short for a floating bar between the two zones, a release docks it at the nearer edge. A floating bar is kept inside a window that shrinks. - Workspaces and preferences keep whether the bar floats and where; older ones dock it at the edge they name. Menus stop at the window's bottom while the bar floats. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/app.rs | 4 +- crates/ui-egui/src/chrome.rs | 7 +- crates/ui-egui/src/chrome/control_bar.rs | 433 +++++++++++++++-------- crates/ui-egui/src/chrome/tests.rs | 259 ++++++++++++-- crates/ui-egui/src/menus/run.rs | 8 +- crates/ui-egui/src/menus/state.rs | 4 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/shortcuts.rs | 10 +- crates/ui-egui/src/state.rs | 15 +- crates/ui-egui/src/workspaces.rs | 53 +++ docs/mcp.md | 17 +- docs/target-app-parity.md | 2 +- 13 files changed, 618 insertions(+), 198 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e449bd52cc..d8ad295cc5 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -469,7 +469,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.control.dock", "Dock Control Bar", "", - "{edge: \"top\"|\"bottom\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}", + "{edge: \"top\"|\"bottom\"|\"float\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do, or float it over the window at the position it last floated at (first: its top-left corner at 60, 80 in window points), as a gripper drag released away from both edges does; kept with the workspace; another edge is an error and changes nothing; returns {edge}", ), ( "window.control.options", diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index bc586767c2..67e67ac444 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -577,10 +577,10 @@ impl VectorcraftApp { chrome::hint_bar(self, ui); } if (self.ui.screen_mode >= 3 || (!self.ui.status_bar && !self.ui.help_bar)) - && !(self.ui.control_bar && self.ui.control_bar_bottom && self.ui.screen_mode < 2) + && !(self.ui.control_bar && self.ui.control_bar_bottom && !self.ui.control_bar_floating && self.ui.screen_mode < 2) { // Keeps the previous frame's value valid for menus drawn above, before the bars. A - // Control bar docked at the bottom has set it to the bar's top. + // Control bar docked at the bottom has set it to the bar's top; a floating one sets none. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } if self.ui.toolbar && self.ui.screen_mode < 3 { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 513aa54df4..855d542d08 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -18,9 +18,12 @@ mod hints; mod status_bar; mod tabs; +pub use control_bar::{ + AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, control_bar_moving, control_bar_placement, + read_hidden_groups, +}; #[cfg(test)] -use control_bar::ISOLATION_BUTTON; -pub use control_bar::{AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, read_hidden_groups}; +use control_bar::{CONTROL_BAR_FLOAT_AT, ISOLATION_BUTTON}; #[cfg(test)] use hints::hint_segments; pub use hints::{hint_bar, hint_strings}; diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index e7c4145397..ecd43ce55d 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -302,9 +302,18 @@ fn artboard_align_group(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// The edges of the window the Control bar docks to (`window.control.dock {edge}`): the top, then -/// the bottom. -pub const CONTROL_BAR_EDGES: [&str; 2] = ["top", "bottom"]; +/// The places the Control bar goes to (`window.control.dock {edge}`): docked to the top or the +/// bottom of the window, or floating over it. +pub const CONTROL_BAR_EDGES: [&str; 3] = ["top", "bottom", "float"]; + +/// Where `window.control.dock {edge: "float"}` floats the Control bar the first time: its top-left +/// corner in window points. +pub const CONTROL_BAR_FLOAT_AT: [f32; 2] = [60.0, 80.0]; + +/// The entry of [`CONTROL_BAR_EDGES`] the Control bar is at in `ui`. +pub fn control_bar_placement(ui: &crate::state::UiState) -> &'static str { + CONTROL_BAR_EDGES[if ui.control_bar_floating { 2 } else { usize::from(ui.control_bar_bottom) }] +} /// The groups of the Control bar's options that its panel menu turns on or off /// (`window.control.options {group, visible?}`), by id, in the menu's order. Every group is on @@ -369,6 +378,74 @@ const MENU_BUTTON: f32 = 16.0; /// The height of the band drawn along the edge of the window that a gripper drag docks the bar to. const DOCK_BAND: f32 = 4.0; +/// The Control bar's height, docked or floating. +const HEIGHT: f32 = 34.0; + +/// The Control bar's inner margin left and right, around its gripper and its panel menu button. +const MARGIN: f32 = 10.0; + +/// The width the floating Control bar takes in a window wide enough for it. +const FLOAT_WIDTH: f32 = 800.0; + +/// How near the edge the bar docks to a gripper drag is released for the bar to dock there; a +/// release farther from both edges floats the bar. +const DOCK_ZONE: f32 = 24.0; + +/// The key of a gripper drag in progress: where the pointer holds the bar from its top-left +/// corner. +fn move_id() -> egui::Id { + egui::Id::unique("control_bar_gripper_move") +} + +/// Is the Control bar's gripper being dragged? An Escape then cancels the drag, and nothing else. +/// A drag left over from a bar hidden while the button was held ends with the button. +pub fn control_bar_moving(ctx: &egui::Context) -> bool { + ctx.input(|i| i.pointer.primary_down()) && ctx.data(|d| d.get_temp::(move_id()).is_some()) +} + +/// Where a gripper release puts the Control bar. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Release { + /// Docked to the bottom (true) or the top (false). + Edge(bool), + /// Floating, its top-left corner here. + Float(egui::Pos2), +} + +/// The width of the floating Control bar in a window whose room below the menu bar is `room`. +fn float_width(room: egui::Rect) -> f32 { + FLOAT_WIDTH.min(room.width()).max(2.0 * MARGIN + MENU_BUTTON + 6.0) +} + +/// The floating Control bar's rect for its saved corner `pos`, kept inside `room`, the window below +/// the menu bar, so its gripper and panel menu stay in reach. +fn float_rect(pos: Option<[f32; 2]>, room: egui::Rect) -> egui::Rect { + let size = vec2(float_width(room), HEIGHT); + egui::Rect::from_min_size(crate::tools_float::clamp(pos.unwrap_or(CONTROL_BAR_FLOAT_AT), size, room), size) +} + +/// Where a gripper drag released at `at` puts the bar, held at `grab` from its top-left corner: +/// within [`DOCK_ZONE`] of the top or the bottom of `room` (the window below the menu bar) docked +/// there, farther from both floating under the pointer. In a window too short for the two zones +/// and a floating bar between them, it docks at the nearer edge. A release outside `window` puts +/// it nowhere, and the bar stays where it was. +fn release_at(at: egui::Pos2, grab: egui::Vec2, room: egui::Rect, window: egui::Rect) -> Option { + if !window.contains(at) || !at.x.is_finite() || !at.y.is_finite() { + return None; + } + if room.height() < 2.0 * DOCK_ZONE + HEIGHT { + return Some(Release::Edge(at.y > room.center().y)); + } + Some(if at.y < room.top() + DOCK_ZONE { + Release::Edge(false) + } else if at.y > room.bottom() - DOCK_ZONE { + Release::Edge(true) + } else { + let corner = at - grab; + Release::Float(float_rect(Some([corner.x, corner.y]), room).min) + }) +} + /// Docks the Control bar to `edge` of [`CONTROL_BAR_EDGES`], through its command. fn dock(app: &mut VectorcraftApp, edge: &str) { crate::menus::invoke(app, "window.control.dock", json!({ "edge": edge })); @@ -376,7 +453,8 @@ fn dock(app: &mut VectorcraftApp, edge: &str) { /// The Control bar's panel menu: an item for each group of [`CONTROL_BAR_GROUPS`], checked while /// the group is on, which turns it on or off; then Dock to Top and Dock to Bottom, the edge the bar -/// is docked to checked. In a window too short for every item, the list scrolls. +/// is docked to checked, and neither while it floats. In a window too short for every item, the +/// list scrolls. fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { let resp = crate::panels::menu_button(ui, "control", rect); egui::Popup::menu(&resp).show(|ui| { @@ -389,7 +467,7 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { } ui.separator(); for (i, label) in [tl!("Dock to Top"), tl!("Dock to Bottom")].into_iter().enumerate() { - if widgets::menu_item(ui, label, true, usize::from(app.ui.control_bar_bottom) == i) { + if widgets::menu_item(ui, label, true, control_bar_placement(&app.ui) == CONTROL_BAR_EDGES[i]) { dock(app, CONTROL_BAR_EDGES[i]); } } @@ -452,170 +530,227 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: } } -/// The Control bar's gripper. Dragging it shows a band along the top or the bottom edge of the -/// window, whichever the pointer is nearer, and docks the bar there on release. -fn gripper(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { +/// The Control bar's gripper, at the left end of the bar `bar`. Dragging it shows where a release +/// puts the bar ([`release_at`] in `dock_room`, the window below the menu bar): a band along the +/// edge it docks to, or the outline of the bar floating under the pointer. The bar stays where it is +/// until the release. Escape, or the window losing the focus, cancels the drag, and a release +/// outside the window leaves the bar where it was. +fn gripper(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, dock_room: egui::Rect) { let (g, resp) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::drag()); for i in 0..5 { ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); } let resp = resp.on_hover_cursor(egui::CursorIcon::Grab); - if !resp.dragged() && !resp.drag_stopped() { + let ctx = ui.ctx().clone(); + if resp.drag_started() + && let Some(press) = ctx.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|d| d.insert_temp(move_id(), press - bar.min)); + } + let Some(grab) = ctx.data(|d| d.get_temp::(move_id())) else { return }; + let canceled = ctx.input(|i| i.key_pressed(egui::Key::Escape) || !i.focused); + if canceled || !(resp.dragged() || resp.drag_stopped()) { + ctx.data_mut(|d| d.remove::(move_id())); return; } - let Some(pos) = resp.interact_pointer_pos().or_else(|| ui.ctx().pointer_latest_pos()) else { return }; - let window = ui.ctx().content_rect(); - let bottom = pos.y > window.center().y; + let window = ctx.content_rect(); + let target = ctx.input(|i| i.pointer.latest_pos()).and_then(|at| release_at(at, grab, dock_room, window)); if resp.drag_stopped() { - if bottom != app.ui.control_bar_bottom { - dock(app, CONTROL_BAR_EDGES[usize::from(bottom)]); + ctx.data_mut(|d| d.remove::(move_id())); + match target { + Some(Release::Edge(bottom)) if app.ui.control_bar_floating || bottom != app.ui.control_bar_bottom => { + dock(app, CONTROL_BAR_EDGES[usize::from(bottom)]); + } + Some(Release::Float(at)) => { + app.ui.control_bar_float_pos = Some([at.x, at.y]); + if !app.ui.control_bar_floating { + dock(app, CONTROL_BAR_EDGES[2]); + } + } + _ => {} } return; } - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - let band = if bottom { - egui::Rect::from_min_max(egui::pos2(window.left(), window.bottom() - DOCK_BAND), window.max) - } else { - egui::Rect::from_min_max(window.min, egui::pos2(window.right(), window.top() + DOCK_BAND)) - }; - let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("control_bar_dock_band")); - ui.ctx().layer_painter(layer).rect_filled(band, 0.0, t.accent); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("control_bar_dock_band"))); + match target { + Some(Release::Edge(bottom)) => { + let band = if bottom { + egui::Rect::from_min_max(egui::pos2(dock_room.left(), dock_room.bottom() - DOCK_BAND), dock_room.max) + } else { + egui::Rect::from_min_max(dock_room.min, egui::pos2(dock_room.right(), dock_room.top() + DOCK_BAND)) + }; + painter.rect_filled(band, 0.0, t.accent); + } + Some(Release::Float(at)) => { + let outline = egui::Rect::from_min_size(at, vec2(float_width(dock_room), HEIGHT)); + painter.rect_stroke(outline, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } + None => {} + } } /// The Control bar (Window → Control), context-sensitive like Illustrator's, docked to the top or -/// the bottom of the window. +/// the bottom of the window, or floating over the window below the menu bar, where it takes no room +/// from the canvas. A floating bar is [`FLOAT_WIDTH`] wide, or as wide as the window when that is +/// narrower, and is kept inside the window when the window shrinks. pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // The window below the menu bar: where the bar docks at the top, and where it floats. + let dock_room = ui.available_rect_before_wrap(); + let frame = egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border)); + if app.ui.control_bar_floating { + let bar = float_rect(app.ui.control_bar_float_pos, dock_room); + let ctx = ui.ctx().clone(); + let area = egui::Area::new(egui::Id::unique("control_bar_floating")).order(egui::Order::Middle).fixed_pos(bar.min).constrain(false); + area.show(&ctx, |ui| { + frame.show(ui, |ui| { + // Inside the frame's margins and its 1-point border. + ui.set_width(bar.width() - 2.0 * MARGIN - 2.0); + ui.set_height(HEIGHT - 2.0); + contents(app, ui, &t, bar, dock_room, true); + }); + }); + return; + } let bottom = app.ui.control_bar_bottom; let panel = if bottom { egui::Panel::bottom("control_bar") } else { egui::Panel::top("control_bar") }; - let bar = panel - .exact_size(34.0) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - // The panel menu button takes the right end, and the controls trim to the room left of it: - // they are laid out in that room, so every room check below leaves the button's width. - let full = ui.max_rect(); - let menu = egui::Rect::from_center_size(egui::pos2(full.right() - MENU_BUTTON / 2.0, full.center().y), vec2(MENU_BUTTON, MENU_BUTTON)); - let room = egui::Rect::from_min_max(full.min, egui::pos2(menu.left() - ui.spacing().item_spacing.x, full.bottom())); - let layout = egui::Layout::left_to_right(egui::Align::Center); - let controls = ui.scope_builder(egui::UiBuilder::new().max_rect(room).layout(layout), |ui| { - gripper(app, ui, &t); - if app.session.active().is_none() { - ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); - return; - } - // The Artboard tool's controls fill the narrowest window, so they keep it to themselves there. - if group_on(app, "snapping") && (app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH) { - snapping_menu(app, ui); - } - let Some(st) = app.session.active() else { return }; - let sel = st.selection.objects.clone(); - let units = app.session.general_unit(); - let first = sel.first().and_then(|id| st.doc.node(*id)); - let anchors = anchor_controls(app); - let label = match first { - _ if app.session.tool_id() == "artboard" => tl!("Artboard"), - Some(_) if sel.len() == 1 && anchors != AnchorControls::None => { - tl!("Anchor Point") - } - Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => { - if im.link.is_some() { - tl!("Linked File") - } else { - tl!("Embedded") - } - } - Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => { - tl!("Image Tracing") - } - _ => tl!(crate::panels::appearance::object_label(app)), - }; - let has_first = first.is_some(); - ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); - // With the Artboard tool in use, the bar shows the active artboard's controls in place - // of the object controls. - let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); - if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) { - artboard_align_group(app, ui); - return; - } - if anchors != AnchorControls::None && group_on(app, "anchors") { - anchor_buttons(app, ui, anchors); - } - let object_options = group_on(app, "objectOptions"); - if object_options { - isolation_button(app, ui); - } - ui.add_space(6.0); - // An image or an Image Trace object shows its own controls in place of Fill and Stroke, - // and leaves Fill and Stroke out while the Image Options group is off too. - let image = if group_on(app, "imageOptions") { - crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui) - } else { - crate::place::selected_image_info(app).is_some() || crate::panels::image_trace::selected_trace(app).is_some() - }; - if group_on(app, "toolOptions") { - crate::toolbar::control_bar_options(app, ui); - } - if object_options { - crate::dialogs::envelope::control_bar(app, ui); - crate::panels::symbols::control_bar(app, ui); - } - let opacity = if has_first { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; - if !image && group_on(app, "fillStroke") { - fill_and_stroke(app, ui); - } - opacity_and_style(app, ui, &t, opacity, !sel.is_empty()); - if group_on(app, "character") { - crate::panels::character::control_bar(app, ui); - } - if !groups_before_transform(app, ui, sel.len()) { - return; - } - // The Transform link opens the whole Transform panel (reference point, rotate, - // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. - crate::panels::transform::link(app, ui); - // The bounding box, rotated with rotated objects: its centre and its own sides. - if let Some(b) = app.selection_box() - && ui.available_width() >= inline_transform_width(ui, &t) - { - let c = b.center(); - let origin = app.ruler_origin(); - let link = crate::panels::transform::proportional(app, ui); - let copy = crate::panels::transform::alt_enter_copies(app, ui); - let values = [(c.x, origin.x), (c.y, origin.y), (b.rect.width(), 0.0), (b.rect.height(), 0.0)]; - // The W/H link sits between W and H, but is made after H so Tab goes from W - // straight to H and finds the link after it. - let mut link_room = None; - for ((k, lbl), (v, offset)) in INLINE_FIELDS.into_iter().zip(values) { - if k == "height" { - link_room = Some(crate::panels::transform::constrain_link_room(ui)); - } - widgets::field_label(ui, inline_label(&t, lbl)); - let (id, shown) = (("cb", k), Some(v - offset)); - let set = if matches!(k, "width" | "height") { - widgets::size_field(ui, id, shown, units, INLINE_FIELD_WIDTH) - } else { - widgets::num_field(ui, id, shown, units, INLINE_FIELD_WIDTH) - }; - if let Some(nv) = set { - app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link, "copy": copy})).ok(); - } - } - if let Some(room) = link_room { - let id = ui.scope_id().with("cb-constrain-link"); - crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click())); - } - } - }); - // Controls that keep their size can still run into the button's room in a narrow window; - // the button is left out then. - if controls.response.rect.right() <= menu.left() { - panel_menu(app, ui, menu); - } - }); + let bar = panel.exact_size(HEIGHT).frame(frame).show(ui, |ui| { + // Out to the frame's 1-point border. + let outer = ui.max_rect().expand2(vec2(MARGIN + 1.0, 1.0)); + contents(app, ui, &t, outer, dock_room, false); + }); // Docked at the bottom, the bar is a bottom bar that menus stop above. if bottom { widgets::set_menu_floor(ui.ctx(), bar.response.rect.top()); } } + +/// The Control bar's gripper, controls and panel menu, in the bar `bar`. A `floating` bar clips its +/// controls to the room left of the panel menu button, and always shows the button. +fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, dock_room: egui::Rect, floating: bool) { + // The panel menu button takes the right end, and the controls trim to the room left of it: + // they are laid out in that room, so every room check below leaves the button's width. + let full = ui.max_rect(); + let menu = egui::Rect::from_center_size(egui::pos2(full.right() - MENU_BUTTON / 2.0, full.center().y), vec2(MENU_BUTTON, MENU_BUTTON)); + let room = egui::Rect::from_min_max(full.min, egui::pos2(menu.left() - ui.spacing().item_spacing.x, full.bottom())); + let layout = egui::Layout::left_to_right(egui::Align::Center); + let controls = ui.scope_builder(egui::UiBuilder::new().max_rect(room).layout(layout), |ui| { + if floating { + ui.set_clip_rect(room.intersect(ui.clip_rect())); + } + gripper(app, ui, t, bar, dock_room); + if app.session.active().is_none() { + ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); + return; + } + // The Artboard tool's controls fill the narrowest window, so they keep it to themselves there. + if group_on(app, "snapping") && (app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH) { + snapping_menu(app, ui); + } + let Some(st) = app.session.active() else { return }; + let sel = st.selection.objects.clone(); + let units = app.session.general_unit(); + let first = sel.first().and_then(|id| st.doc.node(*id)); + let anchors = anchor_controls(app); + let label = match first { + _ if app.session.tool_id() == "artboard" => tl!("Artboard"), + Some(_) if sel.len() == 1 && anchors != AnchorControls::None => { + tl!("Anchor Point") + } + Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => { + if im.link.is_some() { + tl!("Linked File") + } else { + tl!("Embedded") + } + } + Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => { + tl!("Image Tracing") + } + _ => tl!(crate::panels::appearance::object_label(app)), + }; + let has_first = first.is_some(); + ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); + // With the Artboard tool in use, the bar shows the active artboard's controls in place + // of the object controls. + let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); + if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) { + artboard_align_group(app, ui); + return; + } + if anchors != AnchorControls::None && group_on(app, "anchors") { + anchor_buttons(app, ui, anchors); + } + let object_options = group_on(app, "objectOptions"); + if object_options { + isolation_button(app, ui); + } + ui.add_space(6.0); + // An image or an Image Trace object shows its own controls in place of Fill and Stroke, + // and leaves Fill and Stroke out while the Image Options group is off too. + let image = if group_on(app, "imageOptions") { + crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui) + } else { + crate::place::selected_image_info(app).is_some() || crate::panels::image_trace::selected_trace(app).is_some() + }; + if group_on(app, "toolOptions") { + crate::toolbar::control_bar_options(app, ui); + } + if object_options { + crate::dialogs::envelope::control_bar(app, ui); + crate::panels::symbols::control_bar(app, ui); + } + let opacity = if has_first { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; + if !image && group_on(app, "fillStroke") { + fill_and_stroke(app, ui); + } + opacity_and_style(app, ui, t, opacity, !sel.is_empty()); + if group_on(app, "character") { + crate::panels::character::control_bar(app, ui); + } + if !groups_before_transform(app, ui, sel.len()) { + return; + } + // The Transform link opens the whole Transform panel (reference point, rotate, + // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. + crate::panels::transform::link(app, ui); + // The bounding box, rotated with rotated objects: its centre and its own sides. + if let Some(b) = app.selection_box() + && ui.available_width() >= inline_transform_width(ui, t) + { + let c = b.center(); + let origin = app.ruler_origin(); + let link = crate::panels::transform::proportional(app, ui); + let copy = crate::panels::transform::alt_enter_copies(app, ui); + let values = [(c.x, origin.x), (c.y, origin.y), (b.rect.width(), 0.0), (b.rect.height(), 0.0)]; + // The W/H link sits between W and H, but is made after H so Tab goes from W + // straight to H and finds the link after it. + let mut link_room = None; + for ((k, lbl), (v, offset)) in INLINE_FIELDS.into_iter().zip(values) { + if k == "height" { + link_room = Some(crate::panels::transform::constrain_link_room(ui)); + } + widgets::field_label(ui, inline_label(t, lbl)); + let (id, shown) = (("cb", k), Some(v - offset)); + let set = if matches!(k, "width" | "height") { + widgets::size_field(ui, id, shown, units, INLINE_FIELD_WIDTH) + } else { + widgets::num_field(ui, id, shown, units, INLINE_FIELD_WIDTH) + }; + if let Some(nv) = set { + app.run("object.setBounds", json!({k: nv + offset, "reference": 4, "proportional": link, "copy": copy})).ok(); + } + } + if let Some(room) = link_room { + let id = ui.scope_id().with("cb-constrain-link"); + crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click())); + } + } + }); + // Controls that keep their size can still run into the button's room in a narrow window; + // the button is left out then. A floating bar clips them, so its button always shows. + if floating || controls.response.rect.right() <= menu.left() { + panel_menu(app, ui, menu); + } +} diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 9350180586..e26dcdc593 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2354,49 +2354,250 @@ fn the_control_bar_panel_menu_scrolls_in_a_short_window() { assert!(app.ui.control_bar_bottom, "Dock to Bottom docks the bar at the bottom"); } -/// Dragging the Control bar's gripper docks the bar at the edge of the window the pointer is -/// released nearer; a drag released nearer the edge it is at leaves it there. +/// The gripper of a Control bar docked at the top or the bottom of [`DOCK_SCREEN`]: the bar's first +/// item, inside its 10-point margin, centered in the 34-point bar. +fn docked_grip(bottom: bool) -> egui::Pos2 { + egui::pos2(13.0, if bottom { DOCK_SCREEN.bottom() - 17.0 } else { 17.0 }) +} + +/// The gripper of the Control bar floating with its top-left corner at `pos`: inside its 1-point +/// border and 10-point margin. +fn floating_grip(pos: [f32; 2]) -> egui::Pos2 { + egui::pos2(pos[0] + 14.0, pos[1] + 17.0) +} + +/// A drag of the Control bar's gripper from `from` to `to` in window `screen`, with `during` sent +/// at the last move before the release, released at `to`. +fn drag_gripper_in( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + screen: egui::Rect, + from: egui::Pos2, + to: egui::Pos2, + during: Vec, +) { + // A bar that has just started floating takes a frame to show. + control_bar_frame_in(app, ctx, screen, vec![]); + control_bar_frame_in(app, ctx, screen, vec![]); + control_bar_frame_in(app, ctx, screen, vec![egui::Event::PointerMoved(from), press_at(from, true)]); + for k in 1..=4 { + control_bar_frame_in(app, ctx, screen, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); + } + control_bar_frame_in(app, ctx, screen, during); + control_bar_frame_in(app, ctx, screen, vec![press_at(to, false)]); + control_bar_frame_in(app, ctx, screen, vec![]); +} + +/// [`drag_gripper_in`] in [`DOCK_SCREEN`] with nothing sent during the drag. +fn drag_gripper(app: &mut crate::VectorcraftApp, ctx: &egui::Context, from: egui::Pos2, to: egui::Pos2) { + drag_gripper_in(app, ctx, DOCK_SCREEN, from, to, vec![]); +} + +/// Dragging the Control bar's gripper and releasing it within 24 points of the top or the bottom of +/// the window docks the bar at that edge, the edge it is at included. #[test] fn dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge() { let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - // The gripper is the bar's first item, inside its 10-point margin, centered in the 34-point bar. - let grip = |bottom: bool| egui::pos2(13.0, if bottom { DOCK_SCREEN.bottom() - 17.0 } else { 17.0 }); - let drag = |app: &mut crate::VectorcraftApp, from: egui::Pos2, to: egui::Pos2| { - control_bar_frame(app, &ctx, vec![]); - control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(from), press_at(from, true)]); - for k in 1..=4 { - control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); - } - control_bar_frame(app, &ctx, vec![press_at(to, false)]); - }; - drag(&mut app, grip(false), egui::pos2(400.0, 200.0)); - assert!(app.ui.control_bar_bottom, "released nearer the bottom: docked at the bottom"); - drag(&mut app, grip(true), egui::pos2(400.0, 160.0)); - assert!(app.ui.control_bar_bottom, "released nearer the bottom again: still at the bottom"); - drag(&mut app, grip(true), egui::pos2(400.0, 120.0)); - assert!(!app.ui.control_bar_bottom, "released nearer the top: docked at the top"); - drag(&mut app, grip(false), egui::pos2(400.0, 140.0)); - assert!(!app.ui.control_bar_bottom, "released nearer the top again: still at the top"); + drag_gripper(&mut app, &ctx, docked_grip(false), egui::pos2(400.0, 290.0)); + assert!(app.ui.control_bar_bottom && !app.ui.control_bar_floating, "released at the bottom: docked at the bottom"); + drag_gripper(&mut app, &ctx, docked_grip(true), egui::pos2(400.0, 280.0)); + assert!(app.ui.control_bar_bottom && !app.ui.control_bar_floating, "released at the bottom again: still at the bottom"); + drag_gripper(&mut app, &ctx, docked_grip(true), egui::pos2(400.0, 10.0)); + assert!(!app.ui.control_bar_bottom && !app.ui.control_bar_floating, "released at the top: docked at the top"); + drag_gripper(&mut app, &ctx, docked_grip(false), egui::pos2(400.0, 20.0)); + assert!(!app.ui.control_bar_bottom && !app.ui.control_bar_floating, "released at the top again: still at the top"); } -/// `window.control.dock {edge}` docks the Control bar to the top or the bottom of the window, and -/// the panel menu checks the edge in use; another edge, or none, is an error and changes nothing. +/// Dragging the docked Control bar's gripper away from both edges and releasing it there floats +/// the bar under the pointer, held where the gripper was grabbed: 800 points wide, over the window, +/// with its gripper and panel menu. The bar stays docked until the release. #[test] -fn the_control_bar_dock_command_takes_top_or_bottom() { +fn dragging_the_control_bar_gripper_away_from_both_edges_floats_it() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let from = docked_grip(false); + control_bar_frame(&mut app, &ctx, vec![]); + control_bar_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), press_at(from, true)]); + let to = egui::pos2(400.0, 150.0); + for k in 1..=4 { + control_bar_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); + } + assert!(!app.ui.control_bar_floating && super::control_bar_moving(&ctx), "still docked while the drag goes on"); + control_bar_frame(&mut app, &ctx, vec![press_at(to, false)]); + assert!(app.ui.control_bar_floating && !super::control_bar_moving(&ctx), "released away from both edges: floating"); + assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "float"})), Some(true)); + assert_eq!(app.ui.control_bar_float_pos, Some([to.x - from.x, to.y - from.y]), "held where the gripper was grabbed"); + control_bar_frame(&mut app, &ctx, vec![]); + let menu = node_center(&control_bar_frame(&mut app, &ctx, vec![]), "Panel menu").unwrap_or_else(|| panic!("the floating bar has a panel menu")); + let left = to.x - from.x; + assert!((menu.y - to.y).abs() < 1.0 && (menu.x - (left + 800.0 - 19.0)).abs() < 1.0, "the panel menu ends the 800-point bar: {menu:?}"); +} + +/// A floating Control bar's gripper moves it, and docks it again at the top or the bottom of the +/// window; from either edge it floats again. +#[test] +fn floating_control_bar_moves_and_redocks_from_each_edge() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("window.control.dock", json!({"edge": "float"})).unwrap(); + let at = super::CONTROL_BAR_FLOAT_AT; + drag_gripper(&mut app, &ctx, floating_grip(at), egui::pos2(300.0, 100.0)); + assert!(app.ui.control_bar_floating, "moved, it still floats"); + let moved = [300.0 - 14.0, 100.0 - 17.0]; + assert_eq!(app.ui.control_bar_float_pos, Some(moved)); + drag_gripper(&mut app, &ctx, floating_grip(moved), egui::pos2(500.0, 290.0)); + assert!(!app.ui.control_bar_floating && app.ui.control_bar_bottom, "released at the bottom: docked there"); + drag_gripper(&mut app, &ctx, docked_grip(true), egui::pos2(500.0, 160.0)); + assert!(app.ui.control_bar_floating, "from the bottom it floats again"); + let [x, y] = app.ui.control_bar_float_pos.unwrap_or_default(); + drag_gripper(&mut app, &ctx, floating_grip([x, y]), egui::pos2(500.0, 8.0)); + assert!(!app.ui.control_bar_floating && !app.ui.control_bar_bottom, "released at the top: docked there"); + drag_gripper(&mut app, &ctx, docked_grip(false), egui::pos2(700.0, 120.0)); + assert!(app.ui.control_bar_floating, "from the top it floats again"); +} + +/// A floating Control bar's panel menu keeps Dock to Top and Dock to Bottom, neither checked, and +/// each docks the bar at its edge. +#[test] +fn floating_control_bar_menu_docks_to_either_edge() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut crate::VectorcraftApp, events| control_bar_frame_in(app, &ctx, MENU_SCREEN, events); + for (item, bottom) in [("Dock to Bottom", true), ("Dock to Top", false)] { + app.run("window.control.dock", json!({"edge": "float"})).unwrap(); + frame(&mut app, vec![]); + let menu = node_center(&frame(&mut app, vec![]), "Panel menu").unwrap_or_else(|| panic!("the floating bar has a panel menu")); + frame(&mut app, vec![egui::Event::PointerMoved(menu), press_at(menu, true)]); + frame(&mut app, vec![press_at(menu, false)]); + let nodes = frame(&mut app, vec![]); + let checked = nodes.iter().filter_map(|n| n.label()).filter(|l| l.starts_with('✓') && l.contains("Dock")).count(); + assert_eq!(checked, 0, "neither edge is checked while the bar floats"); + let at = node_center(&nodes, item).unwrap_or_else(|| panic!("the menu lists {item}")); + frame(&mut app, vec![egui::Event::PointerMoved(at), press_at(at, true)]); + frame(&mut app, vec![press_at(at, false)]); + assert!(!app.ui.control_bar_floating && app.ui.control_bar_bottom == bottom, "{item} docks the bar"); + } +} + +/// Escape during a gripper drag cancels it: a docked bar stays at its edge and a floating one where +/// it floats. +#[test] +fn control_bar_drag_escape_restores_placement() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let escape = + || vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]; + drag_gripper_in(&mut app, &ctx, DOCK_SCREEN, docked_grip(false), egui::pos2(400.0, 150.0), escape()); + assert!(!app.ui.control_bar_floating && !app.ui.control_bar_bottom, "the docked bar stays at the top"); + assert!(!super::control_bar_moving(&ctx), "the drag is over"); + drag_gripper_in(&mut app, &ctx, DOCK_SCREEN, docked_grip(false), egui::pos2(400.0, 290.0), escape()); + assert!(!app.ui.control_bar_bottom, "a canceled drag over the bottom does not dock it there"); + app.run("window.control.dock", json!({"edge": "float"})).unwrap(); + let at = super::CONTROL_BAR_FLOAT_AT; + drag_gripper_in(&mut app, &ctx, DOCK_SCREEN, floating_grip(at), egui::pos2(500.0, 200.0), escape()); + assert!(app.ui.control_bar_floating, "the floating bar still floats"); + assert_eq!(app.ui.control_bar_float_pos, None, "where it floated"); + // A bar hidden in the middle of a drag leaves no drag to take a later Escape once the button + // is released. + let (from, to) = (floating_grip(at), egui::pos2(500.0, 200.0)); + control_bar_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), press_at(from, true)]); + for k in 1..=4 { + control_bar_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); + } + assert!(super::control_bar_moving(&ctx), "the drag goes on"); + let mut out = ctx.run_ui(egui::RawInput { events: vec![press_at(to, false)], screen_rect: Some(DOCK_SCREEN), ..Default::default() }, |_| {}); + out.textures_delta.clear(); + assert!(!super::control_bar_moving(&ctx), "released with the bar hidden, the drag is over"); +} + +/// A gripper drag released outside the window leaves the bar where it was. +#[test] +fn control_bar_drag_release_outside_window_keeps_previous_placement() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + drag_gripper(&mut app, &ctx, docked_grip(false), egui::pos2(400.0, DOCK_SCREEN.bottom() + 120.0)); + assert!(!app.ui.control_bar_floating && !app.ui.control_bar_bottom, "the bar stays at the top"); + app.run("window.control.dock", json!({"edge": "float"})).unwrap(); + drag_gripper(&mut app, &ctx, floating_grip(super::CONTROL_BAR_FLOAT_AT), egui::pos2(-50.0, 150.0)); + assert!(app.ui.control_bar_floating && app.ui.control_bar_float_pos.is_none(), "the floating bar stays where it was"); +} + +/// In a window too short for a floating bar between the two docking zones, a release docks the bar +/// at the nearer edge; a floating bar saved on a bigger window is drawn inside a smaller one, its +/// panel menu in reach, without docking it. +#[test] +fn floating_control_bar_stays_reachable_in_tiny_and_resized_windows() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let tiny = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 70.0)); + drag_gripper_in(&mut app, &ctx, tiny, egui::pos2(13.0, 17.0), egui::pos2(300.0, 40.0), vec![]); + assert!(!app.ui.control_bar_floating && app.ui.control_bar_bottom, "released below the middle: docked at the bottom"); + app.run("window.control.dock", json!({"edge": "float"})).unwrap(); + app.ui.control_bar_float_pos = Some([1000.0, 250.0]); + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 120.0)); + control_bar_frame_in(&mut app, &ctx, small, vec![]); + let menu = node_center(&control_bar_frame_in(&mut app, &ctx, small, vec![]), "Panel menu") + .unwrap_or_else(|| panic!("the floating bar has a panel menu")); + assert!(small.shrink(1.0).contains(menu), "the panel menu is inside the smaller window: {menu:?}"); + assert!(app.ui.control_bar_floating, "a smaller window does not dock the bar"); + // The gripper too: a drag from where the bar is drawn moves it. + let grip = egui::pos2(small.right() - 500.0 + 14.0, small.bottom() - 34.0 + 17.0); + drag_gripper_in(&mut app, &ctx, small, grip, egui::pos2(200.0, 60.0), vec![]); + assert!(app.ui.control_bar_floating, "moved inside the smaller window"); + assert_eq!(app.ui.control_bar_float_pos, Some([0.0, 43.0]), "kept inside the window, its gripper under the pointer"); +} + +/// `window.control.dock {edge}` docks the Control bar to the top or the bottom of the window, or +/// floats it, and the panel menu checks the edge in use; another edge, or none, is an error and +/// changes nothing. A floating bar floats at the position it last floated at, docked in between +/// or hidden by Tab and shown again. +#[test] +fn the_control_bar_dock_command_takes_top_bottom_or_float() { let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); assert_eq!(app.run("window.control.dock", json!({"edge": "bottom"})).unwrap(), json!({"edge": "bottom"})); assert!(app.ui.control_bar_bottom); assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "bottom"})), Some(true)); assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "top"})), Some(false)); - for p in [json!({"edge": "left"}), json!({"edge": "Bottom"}), json!({"edge": 1}), json!({})] { + for p in [json!({"edge": "left"}), json!({"edge": "Bottom"}), json!({"edge": "Float"}), json!({"edge": 1}), json!({})] { let err = app.run("window.control.dock", p.clone()).unwrap_err(); - assert_eq!(err, "`edge` must be one of top, bottom", "{p}"); - assert!(app.ui.control_bar_bottom, "{p} leaves the bar at the bottom"); + assert_eq!(err, "`edge` must be one of top, bottom, float", "{p}"); + assert!(app.ui.control_bar_bottom && !app.ui.control_bar_floating, "{p} leaves the bar at the bottom"); } + assert_eq!(app.run("window.control.dock", json!({"edge": "float"})).unwrap(), json!({"edge": "float"})); + assert!(app.ui.control_bar_floating && app.ui.control_bar_bottom, "floating, it keeps the edge it docked to last"); + for edge in ["top", "bottom"] { + assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({ "edge": edge })), Some(false), "{edge}"); + } + assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "float"})), Some(true)); + let err = app.run("window.control.dock", json!({"edge": "side"})).unwrap_err(); + assert_eq!(err, "`edge` must be one of top, bottom, float"); + assert!(app.ui.control_bar_floating, "an error leaves the bar floating"); + app.ui.control_bar_float_pos = Some([240.0, 160.0]); assert_eq!(app.run("window.control.dock", json!({"edge": "top"})).unwrap(), json!({"edge": "top"})); - assert!(!app.ui.control_bar_bottom); + assert!(!app.ui.control_bar_bottom && !app.ui.control_bar_floating); + app.run("window.control.dock", json!({"edge": "float"})).unwrap(); + assert_eq!(app.ui.control_bar_float_pos, Some([240.0, 160.0]), "it floats where it last floated"); + // Tab hides the floating bar, and Tab again shows it floating where it was. + app.ui.control_bar = true; + app.run("window.dock", json!({})).unwrap(); + assert!(!app.ui.control_bar && app.ui.control_bar_floating, "Tab hides the bar"); + app.run("window.dock", json!({})).unwrap(); + assert!(app.ui.control_bar && app.ui.control_bar_floating, "Tab shows it floating again"); + assert_eq!(app.ui.control_bar_float_pos, Some([240.0, 160.0])); } /// The labels of the Control bar panel menu's groups of options, in the menu's order. @@ -2667,6 +2868,10 @@ fn menus_stop_above_a_control_bar_docked_at_the_bottom() { assert_eq!(f, screen.bottom(), "docked at the top: menus stop at the window's bottom"); let f = floor(&mut app, true, true, 0); assert!(f < bar_top - 1.0, "the status and hint bars show above the Control bar: menus stop above them, {f}"); + // Floating after docking at the bottom, the bar is no bottom bar. + app.ui.control_bar_floating = true; + let f = floor(&mut app, false, true, 0); + assert_eq!(f, screen.bottom(), "floating: menus stop at the window's bottom"); } /// With the Artboard tool in use, the Control bar shows the Align buttons after the artboard diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 72e6c25acb..52e35c2485 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -516,13 +516,17 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), "window.helpBar" => flag(&mut app.ui.help_bar), - // The Control bar's panel menu and gripper: the edge of the window the bar docks to. + // The Control bar's panel menu and gripper: the edge of the window the bar docks to, or + // floating over the window at the position it last floated at. "window.control.dock" => { let key = "edge"; let edges = crate::chrome::CONTROL_BAR_EDGES; match edges.iter().position(|e| s(key).as_deref() == Some(*e)) { Some(i) => { - app.ui.control_bar_bottom = i == 1; + app.ui.control_bar_floating = i == 2; + if i < 2 { + app.ui.control_bar_bottom = i == 1; + } Ok(json!({ key: edges[i] })) } None => Err(format!("`{key}` must be one of {}", edges.join(", "))), diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 20e4ba2a77..4e4da87380 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -63,9 +63,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, "window.helpBar" => app.ui.help_bar, - "window.control.dock" => { - p.get("edge").and_then(Value::as_str) == Some(crate::chrome::CONTROL_BAR_EDGES[usize::from(app.ui.control_bar_bottom)]) - } + "window.control.dock" => p.get("edge").and_then(Value::as_str) == Some(crate::chrome::control_bar_placement(&app.ui)), "window.control.options" => p .get("group") .and_then(Value::as_str) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index b0b2db020d..78cd14088b 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -470,7 +470,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.control.dock", "Dock Control Bar", "", - "{edge: \"top\"|\"bottom\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}", + "{edge: \"top\"|\"bottom\"|\"float\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do, or float it over the window at the position it last floated at (first: its top-left corner at 60, 80 in window points), as a gripper drag released away from both edges does; kept with the workspace; another edge is an error and changes nothing; returns {edge}", ), ( "window.control.options", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index db262870ab..0b5794b615 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -487,20 +487,22 @@ fn hold_back_key(app: &VectorcraftApp, ctx: &egui::Context, key: Key) { ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, pressed: true, .. } if *k == key))); } -/// The key of the mark that the Escape held down ended a ruler-origin drag or a floating Tools move. +/// The key of the mark that the Escape held down ended a ruler-origin drag, a floating Tools move +/// or a Control bar gripper drag. fn escape_ended_gesture_id() -> egui::Id { egui::Id::unique("escape-ended-gesture") } -/// Does this frame's Escape end a ruler-origin drag or a floating Tools move? The repeats of an -/// Escape that ended one do too, up to the frame that releases Escape or presses it again. +/// Does this frame's Escape end a ruler-origin drag, a floating Tools move or a Control bar gripper +/// drag? The repeats of an Escape that ended one do too, up to the frame that releases Escape or +/// presses it again. fn escape_ended_gesture(ctx: &egui::Context) -> bool { let (down, pressed, repeated) = ctx.input(|i| { let escape = |repeat: bool| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::Escape, pressed: true, repeat: r, .. } if *r == repeat)); (i.key_down(Key::Escape), escape(false), escape(true)) }); - if crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx) { + if crate::canvas::ruler_origin_dragging(ctx) || crate::tools_float::escape_cancelled_move(ctx) || crate::chrome::control_bar_moving(ctx) { if down { ctx.data_mut(|d| d.insert_temp(escape_ended_gesture_id(), true)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f55db4f5e7..ab7d149b57 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -428,6 +428,15 @@ pub struct UiState { /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). pub control_bar_bottom: bool, + /// The Control bar floats over the window at `control_bar_float_pos` instead of docking to an + /// edge (`window.control.dock {edge: "float"}`); `control_bar_bottom` keeps the edge it last + /// docked to. + #[serde(default)] + pub control_bar_floating: bool, + /// The top-left corner of the floating Control bar in window points, kept while the bar is + /// docked; none: `chrome::CONTROL_BAR_FLOAT_AT`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_bar_float_pos: Option<[f32; 2]>, /// The groups of the Control bar's options (`chrome::CONTROL_BAR_GROUPS`) that its panel menu /// has off (`window.control.options`); none: every group shows. #[serde(deserialize_with = "crate::chrome::read_hidden_groups")] @@ -995,7 +1004,8 @@ pub struct FloatingPanels { pub docked: Option, } -/// Drop a Tools panel position that isn't a number (a hand-edited preferences file or workspace). +/// Drop a floating position of the Tools panel or the Control bar that isn't a number (a +/// hand-edited preferences file or workspace). pub fn sanitize_toolbar_pos(toolbar_pos: &mut Option<[f32; 2]>) { if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) { *toolbar_pos = None; @@ -1019,6 +1029,7 @@ impl UiState { } FloatingFlyout::sanitize(&mut self.floating_flyouts); sanitize_toolbar_pos(&mut self.toolbar_pos); + sanitize_toolbar_pos(&mut self.control_bar_float_pos); crate::edit_toolbar::sanitize(&mut self.toolbar_edits); // `read_preferences` converted the file's older layouts already; this covers a state read // any other way. What it reports lost was dropped there. @@ -1068,6 +1079,8 @@ impl Default for UiState { learn_locate: None, control_bar: false, control_bar_bottom: false, + control_bar_floating: false, + control_bar_float_pos: None, control_bar_hidden_groups: Default::default(), toolbar: true, toolbar_double: false, diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 3466f78f4b..2a764a6968 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -24,6 +24,13 @@ pub struct Workspace { /// The Control bar is docked to the bottom of the window (built-in workspaces, and workspaces /// saved without this field: the top). pub control_bar_bottom: bool, + /// The Control bar floats at `control_bar_float_pos` (built-in workspaces, and workspaces saved + /// without this field: docked at the edge `control_bar_bottom` names). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub control_bar_floating: bool, + /// Where the Control bar floats, or last floated (built-in workspaces: none, the first position). + #[serde(skip_serializing_if = "Option::is_none")] + pub control_bar_float_pos: Option<[f32; 2]>, /// The groups of the Control bar's options its panel menu has off (built-in workspaces, and /// workspaces saved without this field: none). #[serde(skip_serializing_if = "std::collections::BTreeSet::is_empty", deserialize_with = "crate::chrome::read_hidden_groups")] @@ -190,6 +197,8 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { name: name.into(), control_bar: ui.control_bar, control_bar_bottom: ui.control_bar_bottom, + control_bar_floating: ui.control_bar_floating, + control_bar_float_pos: ui.control_bar_float_pos, control_bar_hidden_groups: ui.control_bar_hidden_groups.clone(), rulers: ui.view.rulers, toolbar: ui.toolbar, @@ -221,6 +230,13 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { pub fn apply(ui: &mut UiState, w: &Workspace) { ui.control_bar = w.control_bar; ui.control_bar_bottom = w.control_bar_bottom; + ui.control_bar_floating = w.control_bar_floating; + // A floating workspace floats the bar where it was saved (none: the first position); a docked + // one without a floating position keeps the one the bar last floated at. + if w.control_bar_floating || w.control_bar_float_pos.is_some() { + ui.control_bar_float_pos = w.control_bar_float_pos; + crate::state::sanitize_toolbar_pos(&mut ui.control_bar_float_pos); + } ui.control_bar_hidden_groups = w.control_bar_hidden_groups.clone(); ui.view.rulers = w.rulers; ui.toolbar = w.toolbar; @@ -813,6 +829,43 @@ mod tests { assert!(ui.control_bar && !ui.control_bar_bottom, "an older workspace docks the bar at the top"); } + /// A workspace keeps a floating Control bar and where it floats, through the preferences' JSON + /// too; switching to a docked workspace docks it and keeps that position for the next float. + /// Reset floats the bar where the workspace was saved floating. A workspace or preferences saved + /// before floating keep their top or bottom edge, and a position that is not a number is + /// dropped. + #[test] + fn saved_workspaces_restore_floating_control_bar_position() { + let mut ui = UiState { control_bar: true, control_bar_floating: true, control_bar_float_pos: Some([220.0, 140.0]), ..Default::default() }; + save_as(&mut ui, "Floating").unwrap(); + switch(&mut ui, ESSENTIALS).unwrap(); + assert!(!ui.control_bar_floating && !ui.control_bar_bottom, "Essentials docks the bar at the top"); + assert_eq!(ui.control_bar_float_pos, Some([220.0, 140.0]), "the last floating position is kept while docked"); + switch(&mut ui, "Floating").unwrap(); + assert!(ui.control_bar_floating, "the saved workspace floats the bar again"); + assert_eq!(ui.control_bar_float_pos, Some([220.0, 140.0])); + let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); + assert!(back.control_bar_floating && back.custom_workspaces[0].control_bar_floating, "both survive the preferences' JSON"); + assert_eq!(back.custom_workspaces[0].control_bar_float_pos, Some([220.0, 140.0])); + // Saved before the bar floated: the edge it was docked to, top or bottom. + for bottom in [false, true] { + let old: Workspace = serde_json::from_value(json!({"name": "Old", "controlBar": true, "controlBarBottom": bottom})).unwrap(); + apply(&mut ui, &old); + assert!(!ui.control_bar_floating && ui.control_bar_bottom == bottom, "an older workspace docks the bar at its edge, bottom: {bottom}"); + let old: UiState = serde_json::from_value(json!({"control_bar": true, "control_bar_bottom": bottom})).unwrap(); + assert!(!old.control_bar_floating && old.control_bar_bottom == bottom, "older preferences dock it at their edge, bottom: {bottom}"); + } + // Saved floating at the first position: Reset after a move floats it there again. + let mut ui = UiState { control_bar: true, control_bar_floating: true, ..Default::default() }; + save_as(&mut ui, "First").unwrap(); + ui.control_bar_float_pos = Some([300.0, 90.0]); + reset(&mut ui); + assert!(ui.control_bar_floating, "Reset floats the bar"); + assert_eq!(ui.control_bar_float_pos, None, "at the first position, as the workspace was saved"); + let odd: UiState = serde_json::from_value(json!({"control_bar_floating": true, "control_bar_float_pos": [1e39, 5.0]})).unwrap(); + assert_eq!(odd.sanitized().control_bar_float_pos, None, "a position out of range is dropped"); + } + /// A workspace keeps the groups of Control bar options its panel menu has off: switching /// brings each workspace's back, Reset turns a built-in workspace's on again, and they survive /// the preferences' JSON. A workspace or preferences saved without them, or with groups the bar diff --git a/docs/mcp.md b/docs/mcp.md index 0a6d0cf4b9..173019876a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5763,11 +5763,18 @@ through the views in tab order and add `view` to their reply for a document with reports `windows` (every window as `[document index, view number]`, in tab order), `currentWindow` and each pane's `view`. Views are not saved. `window.control.dock {edge}` (desktop app) docks the Control bar to the top of the window, under the menu bar -(`edge: "top"`, the default), or to its bottom (`"bottom"`), and returns `{edge}`. The bar's panel menu runs it with -Dock to Top and Dock to Bottom, the edge in use checked, and so does a drag of the bar's gripper, which docks the bar at -the edge the pointer is released nearer and shows a band along that edge while it moves. The edge is kept with the -workspace, and a workspace saved without it docks the bar at the top. Another `edge`, or none, fails with "`edge` must -be one of top, bottom" and leaves the bar where it is. +(`edge: "top"`, the default), or to its bottom (`"bottom"`), or floats it over the window (`"float"`), and returns +`{edge}`. A floating bar floats where it last floated; the first time, its top-left corner is at 60, 80 in window +points. It is 800 points wide, or as wide as the window when that is narrower, takes no room from the canvas, and is +kept inside the window below the menu bar. The bar's panel menu runs the command with Dock to Top and Dock to Bottom, +the edge in use checked and neither while the bar floats. So does a drag of the bar's gripper: released within 24 +points of the top (under the menu bar) or the bottom of the window it docks the bar at that edge, showing a band along +that edge while it moves, and released farther from both edges it floats the bar there, showing the bar's outline while +it moves. In a window too short for a floating bar between those two zones, the release docks the bar at the nearer +edge. Escape cancels the drag, and a release outside the window leaves the bar where it was. The edge, whether the bar +floats and where it floats are kept with the workspace, and a workspace saved without them docks the bar at the edge it +names, or at the top. Another `edge`, or none, fails with "`edge` must be one of top, bottom, float" and leaves the bar +where it is. `window.control.options {group, visible?}` (desktop app) turns a group of the Control bar's options on (`visible: true`), off (`false`) or toggles it (omitted), and returns `{group, visible}`. The groups are `snapping` (the Snapping menu), `anchors` (Convert and Anchors), `toolOptions` (the active tool's options), `objectOptions` (Isolate Selected diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 68b93ac4d8..bb270e22d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -618,7 +618,7 @@ since 2026-10-01. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. -- **Control bar docks to the top or the bottom:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked, and dragging the bar's gripper docks it at the top or the bottom edge of the window, whichever the pointer is released nearer, with a band along that edge while it moves (`window.control.dock {edge}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Keeping the edge with the workspace, with older workspaces docking the bar at the top, is VectorCraft's own rule. Not done: dragging the gripper away from both edges to float the bar. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `the_control_bar_dock_command_takes_top_or_bottom` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` in `crates/ui-egui/src/workspaces.rs`. +- **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. From 68ea48965b02ec06222ebdd04e455857750cc646 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:03:50 -0500 Subject: [PATCH 10/22] Swatches: the swatch list fills a taller panel above its footer (#1638) --- crates/ui-egui/src/panels/swatches/body.rs | 12 +++++++-- crates/ui-egui/src/panels/swatches/tests.rs | 30 +++++++++++++++++++++ docs/ui-parity.md | 4 ++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index 611b12d6d3..fbcacdc268 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -49,9 +49,17 @@ pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { let lit = app.session.active().map(|st| highlighted(&st.doc, sel)).unwrap_or_default(); let is_sel = |name: &str| lit.contains(&name); let mut ev = TileEvents::default(); + // In a sized panel the list fills the space above the footer (#1638), as the Artboards list + // does; elsewhere it keeps its 150 pt (200 pt for large thumbnails). + let fixed = widgets::panel_footer_bottom(ui).is_some(); + let list_height = widgets::panel_list_height(ui, if view == View::LargeThumb { 200.0 } else { 150.0 }, 2.0 + ui.spacing().item_spacing.y); widgets::list_box(ui, |ui| { - let max_height = if view == View::LargeThumb { 200.0 } else { 150.0 }; - let out = egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(max_height).show(ui, |ui| { + let scroll = egui::ScrollArea::vertical() + .id_salt("swatch-scroll") + .max_height(list_height) + .min_scrolled_height(list_height.min(64.0)) + .auto_shrink([false, !fixed]); + let out = scroll.show(ui, |ui| { ui.set_width(ui.available_width()); if view.is_list() { for e in &items { diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 3586f91607..deb11f0157 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -901,3 +901,33 @@ fn lab_spot_tiles_show_the_colour_the_spot_colors_options_pick() { assert_eq!(tile(&app), app.session.active().unwrap().doc.global_color("Ink").unwrap()); assert!(matches!(tile(&app), Color::Cmyk { .. })); } + +/// In a panel made taller the swatch list fills the space above the panel's footer instead of +/// scrolling inside 150 or 200 pt (#1638); outside a sized panel it keeps those heights. +#[test] +fn the_list_fills_a_tall_panel_above_its_footer() { + let mut app = app(); + for i in 0..60 { + app.run("swatch.new", json!({"name": format!("Tone {i:02}"), "color": format!("#{:02x}3060", i * 4)})).unwrap(); + } + let ctx = context(); + set_pstate(&ctx, "swatch-view", View::SmallList); + // The clip rect of the first swatch row's name: the list's visible area. + let mut list = |bottom: Option| { + let mut clip = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 760.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let info = egui::UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, bottom); + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| show(&mut app, ui)); + }); + out.textures_delta.clear(); + clip = out.shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Tone 00")).map(|c| c.clip_rect); + } + clip.expect("the first new swatch's row is drawn") + }; + let tall = list(Some(700.0)); + assert!(tall.height() > 400.0 && tall.bottom() <= 700.0 - 31.0, "the list fills the panel above its footer: {tall:?}"); + let loose = list(None); + assert!(loose.height() <= 150.5, "unsized, the list keeps its height: {loose:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e5f9b1cf19..4b77bba2ea 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -165,6 +166,7 @@ tools, never checked side by side) isn't captured row by row. | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | | Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | | Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` | +| Swatches list fills a taller panel (#1638) | done | Docked, floating or in a resized flyout, the swatch list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 150 pt (200 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/swatches/tests.rs` | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` | | Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 | From 61f170a761082d9ca059482a2d52b70110a47c25 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:56:22 -0800 Subject: [PATCH 11/22] Mesh: a point on a mesh's outline locates on its edge on every side A point exactly on a gradient mesh's outline located on the top and left edges but not on the bottom and right ones, where the nonzero winding number is 0. object.mesh.addLine and Create Gradient Mesh at a point there reported the point outside the mesh, and the Eyedropper did not sample it, while the hit test selects the mesh there. locate_within now takes the nearest point of the edge for a point within a millionth of a point of the outline, with any tolerance, and returns nothing for a point that is not finite. A point just outside still needs a tolerance. --- crates/doc/src/live.rs | 31 ++++++++++++++++++++++++++ crates/doc/src/live/mesh.rs | 21 ++++++++++++----- crates/engine/src/cmd/live/commands.rs | 2 +- crates/engine/src/tests/live.rs | 22 ++++++++++++++++++ docs/mcp.md | 3 ++- docs/target-app-parity.md | 1 + 6 files changed, 73 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index d1eb20ed21..b4c0f9fdd8 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1025,6 +1025,37 @@ mod tests { assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0))); } + /// A point on a mesh's outline locates on the edge it lies on, on all four sides alike, with no + /// tolerance; a point just outside does not, and a tolerance reaches only as far as it says. + /// A point that isn't finite locates nowhere. + #[test] + fn mesh_locate_takes_a_point_on_any_side_of_the_outline() { + for (rows, cols) in [(1, 1), (2, 3)] { + let m = + GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0) + .unwrap(); + let sides = [ + ("top", Point::new(30.0, 0.0), Vec2::new(0.0, -0.01)), + ("bottom", Point::new(30.0, 100.0), Vec2::new(0.0, 0.01)), + ("left", Point::new(0.0, 70.0), Vec2::new(-0.01, 0.0)), + ("right", Point::new(100.0, 70.0), Vec2::new(0.01, 0.0)), + ]; + for (side, p, out) in sides { + let (r, c, u, v) = m.locate(p).unwrap_or_else(|| panic!("{rows}×{cols} {side}: {p:?} is not located")); + assert!(r < m.rows as usize && c < m.cols as usize && (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v), "{side}"); + assert!(m.eval(r, c, u, v).distance(p) < 1e-3, "{rows}×{cols} {side}: {:?}", m.eval(r, c, u, v)); + let mut e = m.clone(); + assert!(e.add_lines_at(p, 0.0).is_some(), "{rows}×{cols} {side}: no line at {p:?}"); + assert_eq!(m.locate(p + out), None, "{rows}×{cols} {side}: just outside"); + assert_eq!(m.locate_within(p + out * 100.0, 0.5), None, "{rows}×{cols} {side}: past the tolerance"); + assert!(m.locate_within(p + out * 100.0, 1.5).is_some(), "{rows}×{cols} {side}: within the tolerance"); + } + for p in [Point::new(f64::NAN, 0.0), Point::new(30.0, f64::INFINITY)] { + assert_eq!(m.locate_within(p, f64::INFINITY), None, "{p:?}"); + } + } + } + /// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB. #[test] fn a_cmyk_mesh_mixes_its_inks() { diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index be865e2769..91d814a42d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -530,6 +530,10 @@ pub(super) const MAX_BORDER_LINES: usize = 256; /// The most rounds of new lines a mesh made from a path takes to keep its border on the outline. const MAX_BORDER_ROUNDS: usize = 64; +/// A point this close (in points) to a mesh's outline is on it, for [`GradientMesh::locate_within`] +/// with any tolerance. +const ON_OUTLINE: f64 = 1e-6; + /// An anchor whose directions in and out differ by more than this (radians) is a corner: a border /// edge that can't follow the outline takes a mesh line through its sharpest corner first. const SHARP_TURN: f64 = 0.01; @@ -1436,17 +1440,24 @@ impl GradientMesh { } /// As [`Self::locate`], and for a point outside the mesh within `tol` of its outline, the - /// patch and local (u, v) of the nearest point of the mesh's edge. + /// patch and local (u, v) of the nearest point of the mesh's edge. A point on the outline + /// (within [`ON_OUTLINE`]) locates on its edge on every side, with any `tol`. pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> { - if !self.is_valid() { + if !self.is_valid() || !p.is_finite() { return None; } - let bp = self.outline().to_bezpath(); - if bp.winding(p) == 0 { + // A point on the outline takes the nearest point of the edge on every side: the nonzero + // winding number counts a point on some edges as outside (a rectangle's bottom and right + // edges) and on the others as inside. + let edge = self.nearest_edge(p); + if let Some((_, r, c, u, v)) = edge.filter(|e| e.0 <= ON_OUTLINE * ON_OUTLINE) { + return Some((r, c, u, v)); + } + if self.outline().to_bezpath().winding(p) == 0 { if tol.is_nan() || tol <= 0.0 { return None; } - return self.nearest_edge(p).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); + return edge.filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); } const N: usize = 16; let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 8e325c69be..0db3987e29 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -290,7 +290,7 @@ pub fn specs() -> Vec { "Add Mesh Line", [], None, - "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge → {index}", + "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge → {index}", has_doc, mesh_add_line ), diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index f2ab135817..4556e6f75f 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -1184,6 +1184,28 @@ fn mesh_add_line_on_the_edge_from_just_outside() { assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 140, "y": 210, "tolerance": 3})).is_err(), "past the tolerance"); } +/// `object.mesh.addLine` and Create Gradient Mesh `at` a point exactly on the outline add their +/// lines there with no tolerance, on the bottom and right edges as on the top and left ones. +#[test] +fn mesh_add_line_exactly_on_the_edge_needs_no_tolerance() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + let r = s.execute("object.mesh.create", &json!({"at": [50, 100]})).unwrap(); + let m = mesh_of(&s, a); + assert_eq!((m.rows, m.cols), (1, 2), "a column through the bottom edge"); + assert!(m.points[r["index"].as_u64().unwrap() as usize].p.distance(Point::new(50.0, 100.0)) < 0.5); + for (x, y) in [(100.0, 30.0), (20.0, 0.0), (0.0, 80.0)] { + let r = s.execute("object.mesh.addLine", &json!({"id": a.0, "x": x, "y": y})).unwrap(); + let p = mesh_of(&s, a).points[r["index"].as_u64().unwrap() as usize].p; + assert!(p.distance(Point::new(x, y)) < 0.5, "({x}, {y}): {p:?}"); + } + assert_eq!((mesh_of(&s, a).rows, mesh_of(&s, a).cols), (3, 3)); + let n = undo_len(&s); + assert!(s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 60, "y": 100.01})).is_err(), "just outside"); + assert_eq!(undo_len(&s), n, "nothing changed"); +} + /// A mesh point `index`, and `rows`, `cols` or `columns` for Create Gradient Mesh, that is not a /// whole number is an error that changes nothing; 4.0 names point 4. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 3eadc40f4a..30fe6efc65 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9626,7 +9626,8 @@ right/left/down/up; none stored means the smooth mesh through the points, as old `object.envelope.setMeshPoint {id, index, x, y, handle?}` and `object.mesh.movePoint`, `object.mesh.addLine` and `object.mesh.deletePoint` edit them like a gradient mesh's (one undo step each). On the canvas a selected envelope shows its mesh; the Mesh tool and Direct Selection drag its points and the clicked point's handles, and the Mesh tool -adds a row and a column where it clicks inside. `press_key` Escape during a drag of a point or handle of a mesh +adds a row and a column where it clicks inside. `object.mesh.addLine`, and `object.mesh.create` with `at`, take a point exactly on +the mesh's outline (within a millionth of a point) on any side without `tolerance`; a point outside needs `tolerance`. `press_key` Escape during a drag of a point or handle of a mesh envelope or a gradient mesh cancels it: the point goes back and no undo step is added. The Control bar shows Edit Envelope / Edit Contents, the warp's style, orientation, bend and distortions (or the mesh's rows and columns), Reset (an unbent warp, a flat mesh) and Envelope Options. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 11dc8cb895..7046b5f42b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1308,6 +1308,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Gradient mesh from an image:** Object › Create Gradient Mesh (`object.mesh.create`) on an embedded image makes a mesh of 1 to 50 rows and columns over the picture's rectangle, placed with the image's transform; each point takes the alpha-weighted average colour and the opacity of the area around it, and Appearance and Highlight apply. A linked image is refused with a message to embed it (#1676). Scores and hours unchanged. - **Gradient mesh keeps the object's outline (#2752):** Create Gradient Mesh, a Mesh tool click and Expand of a gradient to a mesh lay the mesh's border on the path's outline: each border edge is the piece of the outline between its two points, and where the outline bends through an anchor (a star's points, an L-shape's inner corner, a rounded rectangle's arcs) no single curve follows, the mesh takes a line through that anchor, up to 256 each way. Holes and other subpaths of a compound path are still left out. Public reference: [About mesh objects and Create mesh objects, CS3 user guide, pages 184 to 185](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf) (mesh lines carry anchor points as well as mesh points). Tests: `a_mesh_keeps_the_outline_of_every_shape` and `a_mesh_takes_lines_only_where_the_outline_needs_them` in `crates/doc/src/live.rs`, `a_mesh_tool_click_keeps_a_stars_points` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. +- **A point on a mesh's outline is on the mesh:** a point exactly on the outline of a gradient mesh (within a millionth of a point) locates on the edge it lies on, on all four sides alike and with no tolerance, as it already did on the top and left edges, so `object.mesh.addLine` and Create Gradient Mesh `at` a point on the bottom or right edge add their lines there instead of reporting the point outside, and the Eyedropper samples the mesh's color there; a point just outside still needs `tolerance`. Tests: `mesh_locate_takes_a_point_on_any_side_of_the_outline` in `crates/doc/src/live.rs`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Several mesh points at once (#4207, #4208, #4170):** the Mesh tool and Direct Selection pick several points of a gradient mesh or mesh envelope: Shift-click adds a point or takes a picked one away, Direct Selection's marquee picks the points inside it (Shift toggles them) and selects their meshes, and its click on a mesh line picks the line's two ends and one inside a patch the four points around it. The picked points show filled; dragging one moves them all as one undo step and the arrow keys nudge them (`object.mesh.movePoints {id, points, dx, dy}` or `{items, dx, dy}`), and a click on one of several picked points picks it alone. A solid fill colour (`paint.setFill`: Color panel, Swatches, Eyedropper) recolours every picked point, and the Transparency panel's Opacity fades them instead of the object, the panel showing their opacity (blank when it differs); `object.mesh.setPointColor` takes `points` or `items`. Public reference: [Edit mesh objects](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-meshes/edit-mesh-objects.html) (Shift selects multiple mesh points; a fill colour colours the selected point), [Macworld, gradient mesh](https://www.macworld.com/article/202693/gmesh2.html) (Direct Selection: a mesh line selects the points at both ends, a patch the points around it, Shift-click adds or removes points), [Peachpit](https://www.peachpit.com/articles/article.aspx?p=1638270) (each mesh point can have its own colour or transparency). Tests: `shift_clicks_pick_mesh_points_that_drag_and_nudge_together` and `direct_selection_picks_mesh_points_by_marquee_line_and_patch` in `crates/tools/src/meshedit.rs`, `picked_mesh_points_take_the_fill_opacity_and_drag_together` in `crates/engine/src/tests/editcolors.rs`, `mesh_points_move_and_colour_together` in `crates/engine/src/tests/live.rs`. Scores and hours unchanged. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Image Trace, Flat Logo named colors only:** with `logoColors` named, `"logoWhite": false` traces those colors only, so white areas take the nearest of them and no white is added; a named white is kept, and enclosed transparent areas stay holes; code-drawn tests in `crates/trace` and `crates/engine`. From 17314c51655d5cb3cb3b3206ae9832a980e6df5a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 16:57:29 -0800 Subject: [PATCH 12/22] Control bar: Escape in the frame that releases the gripper cancels only the drag The release frame's shortcuts run before the bar is drawn, with the button already up, so they missed the drag and the Escape also reached the active tool. The drag now counts as going on in that frame too. --- crates/ui-egui/src/chrome/control_bar.rs | 18 +++++++------ crates/ui-egui/src/chrome/tests.rs | 33 ++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index ecd43ce55d..cd833c4a36 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -392,15 +392,17 @@ const FLOAT_WIDTH: f32 = 800.0; const DOCK_ZONE: f32 = 24.0; /// The key of a gripper drag in progress: where the pointer holds the bar from its top-left -/// corner. +/// corner, and the gripper's widget id. fn move_id() -> egui::Id { egui::Id::unique("control_bar_gripper_move") } -/// Is the Control bar's gripper being dragged? An Escape then cancels the drag, and nothing else. -/// A drag left over from a bar hidden while the button was held ends with the button. +/// Is the Control bar's gripper being dragged, the frame that releases it included? An Escape then +/// cancels the drag, and nothing else. A drag left over from a bar hidden while the button was held +/// ends with the button. pub fn control_bar_moving(ctx: &egui::Context) -> bool { - ctx.input(|i| i.pointer.primary_down()) && ctx.data(|d| d.get_temp::(move_id()).is_some()) + let Some((_, gripper)) = ctx.data(|d| d.get_temp::<(egui::Vec2, egui::Id)>(move_id())) else { return false }; + ctx.input(|i| i.pointer.primary_down()) || ctx.drag_stopped_id() == Some(gripper) } /// Where a gripper release puts the Control bar. @@ -545,18 +547,18 @@ fn gripper(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, d if resp.drag_started() && let Some(press) = ctx.input(|i| i.pointer.press_origin()) { - ctx.data_mut(|d| d.insert_temp(move_id(), press - bar.min)); + ctx.data_mut(|d| d.insert_temp(move_id(), (press - bar.min, resp.id))); } - let Some(grab) = ctx.data(|d| d.get_temp::(move_id())) else { return }; + let Some((grab, _)) = ctx.data(|d| d.get_temp::<(egui::Vec2, egui::Id)>(move_id())) else { return }; let canceled = ctx.input(|i| i.key_pressed(egui::Key::Escape) || !i.focused); if canceled || !(resp.dragged() || resp.drag_stopped()) { - ctx.data_mut(|d| d.remove::(move_id())); + ctx.data_mut(|d| d.remove::<(egui::Vec2, egui::Id)>(move_id())); return; } let window = ctx.content_rect(); let target = ctx.input(|i| i.pointer.latest_pos()).and_then(|at| release_at(at, grab, dock_room, window)); if resp.drag_stopped() { - ctx.data_mut(|d| d.remove::(move_id())); + ctx.data_mut(|d| d.remove::<(egui::Vec2, egui::Id)>(move_id())); match target { Some(Release::Edge(bottom)) if app.ui.control_bar_floating || bottom != app.ui.control_bar_bottom => { dock(app, CONTROL_BAR_EDGES[usize::from(bottom)]); diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index e26dcdc593..50c1cdcd0c 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2515,11 +2515,40 @@ fn control_bar_drag_escape_restores_placement() { control_bar_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); } assert!(super::control_bar_moving(&ctx), "the drag goes on"); - let mut out = ctx.run_ui(egui::RawInput { events: vec![press_at(to, false)], screen_rect: Some(DOCK_SCREEN), ..Default::default() }, |_| {}); - out.textures_delta.clear(); + for events in [vec![press_at(to, false)], vec![]] { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(DOCK_SCREEN), ..Default::default() }, |_| {}); + out.textures_delta.clear(); + } assert!(!super::control_bar_moving(&ctx), "released with the bar hidden, the drag is over"); } +/// An Escape in the same frame as the gripper's release is the drag's: the frame's shortcuts, which +/// run before the bar is drawn, still see the drag, and the bar stays where it was. +#[test] +fn control_bar_drag_escape_on_release_frame_is_the_drags() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (from, to) = (docked_grip(false), egui::pos2(400.0, 150.0)); + control_bar_frame(&mut app, &ctx, vec![]); + control_bar_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), press_at(from, true)]); + for k in 1..=4 { + control_bar_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); + } + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let mut moving = None; + let input = egui::RawInput { events: vec![escape, press_at(to, false)], screen_rect: Some(DOCK_SCREEN), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + moving = Some(super::control_bar_moving(ui.ctx())); + super::control_bar(&mut app, ui); + }); + out.textures_delta.clear(); + assert_eq!(moving, Some(true), "the release frame's shortcuts see the drag"); + assert!(!app.ui.control_bar_floating && !app.ui.control_bar_bottom, "the canceled drag leaves the bar at the top"); + control_bar_frame(&mut app, &ctx, vec![]); + assert!(!super::control_bar_moving(&ctx), "the drag is over"); +} + /// A gripper drag released outside the window leaves the bar where it was. #[test] fn control_bar_drag_release_outside_window_keeps_previous_placement() { From aff731a9ca9048306c4972fe4842dfee400c5102 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:11:36 -0800 Subject: [PATCH 13/22] Docs: record the mesh outline fix in the Colour row The Colour, swatches, gradients, patterns, mesh, recolor row of docs/target-app-parity.md now lists the outline fix and its two tests. Score and hours are unchanged. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7046b5f42b..00df182e88 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -225,7 +225,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); deletion splits an ordinary contour into selected sibling objects with bounds-fitted gradients kept in place (#3380, deletion only; `crates/engine/tests/it/delete_path_pieces.rs`); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521), and on a group the groups like it, an edit inside one reaching the same object in the others (#4939), and a Shift-click on a highlighted match leaves it out; the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of rotated copies, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) | | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged; a point on a gradient mesh's outline locates on its edge on every side (#7006: `mesh_locate_takes_a_point_on_any_side_of_the_outline`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance`), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | From 13a4d271d4f34381125c317eeaec499f46911eb8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:24:18 -0800 Subject: [PATCH 14/22] Mesh: locating a point searches only the edges near it locate_within measured every boundary edge of the mesh at full accuracy for each lookup, including points well inside the mesh. It now skips an edge whose control points all lie farther than the distance it is looking for: a millionth of a point for the outline, or the tolerance for a point outside. --- crates/doc/src/live/mesh.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index 91d814a42d..d912479464 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -1449,15 +1449,14 @@ impl GradientMesh { // A point on the outline takes the nearest point of the edge on every side: the nonzero // winding number counts a point on some edges as outside (a rectangle's bottom and right // edges) and on the others as inside. - let edge = self.nearest_edge(p); - if let Some((_, r, c, u, v)) = edge.filter(|e| e.0 <= ON_OUTLINE * ON_OUTLINE) { + if let Some((_, r, c, u, v)) = self.nearest_edge(p, ON_OUTLINE).filter(|e| e.0 <= ON_OUTLINE * ON_OUTLINE) { return Some((r, c, u, v)); } if self.outline().to_bezpath().winding(p) == 0 { if tol.is_nan() || tol <= 0.0 { return None; } - return edge.filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); + return self.nearest_edge(p, tol).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v)); } const N: usize = 16; let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0); @@ -1495,12 +1494,20 @@ impl GradientMesh { } /// The point of the mesh's outer edge nearest `p`: its squared distance from `p`, and its - /// boundary patch and local (u, v). - fn nearest_edge(&self, p: Point) -> Option<(f64, usize, usize, f64, f64)> { + /// boundary patch and local (u, v). Edges whose control points all lie farther than `within` + /// from `p` are skipped, so `None` means no edge is within `within`. + fn nearest_edge(&self, p: Point, within: f64) -> Option<(f64, usize, usize, f64, f64)> { use vectorcraft_geom::kurbo::ParamCurveNearest; let (rows, cols) = (self.rows as usize, self.cols as usize); let mut best: Option<(f64, usize, usize, f64, f64)> = None; let mut take = |e: CubicBez, at: (usize, usize, Option, Option)| { + // The edge lies inside its control points' box, so a box farther than `within` holds + // no point of the edge within it. + let b = Rect::from_points(e.p0, e.p1).union_pt(e.p2).union_pt(e.p3); + let (dx, dy) = ((b.x0 - p.x).max(p.x - b.x1).max(0.0), (b.y0 - p.y).max(p.y - b.y1).max(0.0)); + if dx * dx + dy * dy > within * within { + return; + } let n = e.nearest(p, 1e-9); if best.is_none_or(|b| n.distance_sq < b.0) { let (r, c, u, v) = at; From 8e5360b7b137dd76a84ca5d056af40fdac2273e9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:39:54 -0800 Subject: [PATCH 15/22] Layers: only the primary button drags rows, guides, art, columns and appearances A drag with the right or middle button on a Layers row could move or copy the row, and the same drag on a guide row, the selected-art square, the eye or lock column or a target circle moved the guide or the art, swept visibility or locks, or transferred the appearance. Each of these drags now starts only with the primary button, as the Artboards panel's row drag does, and the grab cursor follows the primary button too. A right-click still opens the row's menu. The new test drags each of the six sources with the secondary and the middle button, checks that no drag payload is held and that nothing in the document or its undo steps changes, then drags the same path with the primary button to show the drop target takes it. --- crates/ui-egui/src/panels/layers/rows.rs | 27 +++-- crates/ui-egui/src/panels/layers/tests.rs | 130 ++++++++++++++++++++-- docs/target-app-parity.md | 1 + 3 files changed, 135 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 13b6ff1e2b..4e5b7eb218 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -152,11 +152,12 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command}))); } // Dragged: the selected guides when this is one of them, else this one, to another layer's row. - if editable && resp.drag_started() { + // Only the primary button drags a guide row. + if editable && resp.drag_started_by(egui::PointerButton::Primary) { let guides = if selected { view.guides.clone() } else { vec![i] }; egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides)); } - if resp.dragged() { + if resp.dragged_by(egui::PointerButton::Primary) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } // Guides dropped here go to this guide's layer. @@ -363,7 +364,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { } let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.scope_id().with(("selsq", n.id.0)), Sense::click_and_drag()); // Dragging the selected-art square carries the selection to another row. - if (is_sel || child_sel) && sq_resp.drag_started() { + if (is_sel || child_sel) && sq_resp.drag_started_by(egui::PointerButton::Primary) { egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art); } let m = ui.input(|i| i.modifiers); @@ -396,8 +397,9 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple }))); }); }); - // Dragging rows: the highlighted rows when this is one of them, else this row. - if resp.drag_started() && view.column.is_none() { + // Dragging rows: the highlighted rows when this is one of them, else this row. Only the primary + // button drags; the secondary button opens the row menu, and a drag with it moves nothing. + if resp.drag_started_by(egui::PointerButton::Primary) && view.column.is_none() { let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] }; egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids)); let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min); @@ -406,7 +408,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { d.insert_temp(key("grabbed"), n.id); }); } - if resp.dragged() { + if resp.dragged_by(egui::PointerButton::Primary) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } drop_target(ui, view, n, r, x - 16.0, open, &resp, out); @@ -489,10 +491,10 @@ fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out } else if lr.clicked() { out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "locked": !n.locked}))); } - // Dragging down a column gives every row it passes this row's new state. - if er.drag_started() { + // Dragging down a column with the primary button gives every row it passes this row's new state. + if er.drag_started_by(egui::PointerButton::Primary) { out.column = Some(Some(ColumnDrag { eye: true, value: !n.visible, ids: vec![n.id.0] })); - } else if lr.drag_started() { + } else if lr.drag_started_by(egui::PointerButton::Primary) { out.column = Some(Some(ColumnDrag { eye: false, value: !n.locked, ids: vec![n.id.0] })); } else if let Some(c) = out.column.clone().unwrap_or_else(|| view.column.clone()) && !c.ids.contains(&n.id.0) @@ -567,7 +569,10 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: /// copies it) or the trash. fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response { let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.scope_id().with(("target", n.id.0)), Sense::click_and_drag()); - widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id)); + // Only the primary button carries the appearance away. + if resp.drag_started_by(egui::PointerButton::Primary) { + egui::DragAndDrop::set_payload(ui.ctx(), PanelDrag::Appearance(n.id)); + } let held = resp.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); if let Some(d) = resp.dnd_release_payload::() && let PanelDrag::Appearance(source) = *d @@ -584,7 +589,7 @@ fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, a if has_styled_target(n) { ui.painter().circle_filled(c, 3.2, t.icon); } - if resp.dragged() { + if resp.dragged_by(egui::PointerButton::Primary) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } resp diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 4b47cbd3fe..0fd8586409 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -94,22 +94,30 @@ fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec } fn button(pos: egui::Pos2, pressed: bool) -> egui::Event { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + button_by(pos, pressed, egui::PointerButton::Primary) +} + +/// `pointer` pressed (or released) at `pos`. +fn button_by(pos: egui::Pos2, pressed: bool, pointer: egui::PointerButton) -> egui::Event { + egui::Event::PointerButton { pos, button: pointer, pressed, modifiers: Default::default() } } /// Drag from `a` to `b` over a few frames (Alt held when `alt`). fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool) { - let steps = [ - vec![egui::Event::PointerMoved(a)], - vec![button(a, true)], - vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], - vec![egui::Event::PointerMoved(b)], - vec![button(b, false)], - vec![], - ]; - for e in steps { - frame(app, ctx, e, alt); - } + drag_by(app, ctx, a, b, alt, egui::PointerButton::Primary); +} + +/// Drag from `a` to `b` with `pointer` over a few frames (Alt held when `alt`). Returns whether a +/// drag-and-drop payload was held over `b` before the release. +fn drag_by(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool, pointer: egui::PointerButton) -> bool { + frame(app, ctx, vec![egui::Event::PointerMoved(a)], alt); + frame(app, ctx, vec![button_by(a, true, pointer)], alt); + frame(app, ctx, vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], alt); + frame(app, ctx, vec![egui::Event::PointerMoved(b)], alt); + let held = egui::DragAndDrop::has_any_payload(ctx); + frame(app, ctx, vec![button_by(b, false, pointer)], alt); + frame(app, ctx, vec![], alt); + held } #[test] @@ -1290,6 +1298,104 @@ fn the_selected_art_square_drags_the_selection_to_another_layer() { assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "still selected"); } +/// The undo steps of the active document. +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// Drags from `a` to `b` with the secondary and then the middle button, each of which must carry +/// nothing and leave the document and its undo steps as they were (`state` reads what the drag +/// would change); then the same drag with the primary button, which must change `state`. +fn only_the_primary_button_drags( + app: &mut VectorcraftApp, + ctx: &egui::Context, + (a, b): (egui::Pos2, egui::Pos2), + state: impl Fn(&VectorcraftApp) -> S, + what: &str, +) { + let (before, undo) = (state(app), undo_len(app)); + for pointer in [egui::PointerButton::Secondary, egui::PointerButton::Middle] { + let held = drag_by(app, ctx, a, b, false, pointer); + assert!(!held, "{what}: a {pointer:?} drag carries nothing"); + assert_eq!(state(app), before, "{what}: a {pointer:?} drag changes nothing"); + assert_eq!(undo_len(app), undo, "{what}: a {pointer:?} drag records no undo step"); + } + drag_by(app, ctx, a, b, false, egui::PointerButton::Primary); + assert_ne!(state(app), before, "{what}: the primary button drags"); +} + +/// Only the primary button drags in the panel: a drag with the secondary or middle button moves no +/// row, guide or selected art, hides or locks no row and moves no appearance, and records no undo +/// step. The secondary button still opens a row's menu. +#[test] +fn only_the_primary_button_drags_rows_guides_art_columns_and_appearances() { + let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone(); + let rows = |app: &mut VectorcraftApp, ctx: &egui::Context| frame(app, ctx, vec![], false).0; + + // A row, onto the middle of another layer's row. + let (mut app, [.., a, _]) = nested(); + app.session.execute("layer.new", &json!({"top": true, "name": "Top"})).unwrap(); + let ctx = egui::Context::default(); + let c = rows(&mut app, &ctx); + let parent = |app: &VectorcraftApp| app.session.active().unwrap().doc.parent_of(a); + only_the_primary_button_drags(&mut app, &ctx, (egui::pos2(150.0, c[5].y), egui::pos2(150.0, c[0].y)), parent, "a row"); + + // A guide row, onto another layer's row. + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); + run(&mut app, "guide.add", json!({"vertical": true, "pos": 50})); + run(&mut app, "select.none", json!({})); + run(&mut app, "layer.new", json!({})); + expand(&mut app, &json!({})).unwrap(); + let ctx = egui::Context::default(); + let c = rows(&mut app, &ctx); + let guide_row = egui::pos2(150.0, (c[1].y + c[2].y) / 2.0); + let layer = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides()[0].layer; + only_the_primary_button_drags(&mut app, &ctx, (guide_row, egui::pos2(150.0, c[0].y)), layer, "a guide row"); + + // The selected-art square, onto the layer's row. + let (mut app, [_, _, g, _, a, _]) = nested(); + app.session.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap(); + let ctx = egui::Context::default(); + let c = rows(&mut app, &ctx); + let kids = |app: &VectorcraftApp| { + let d = &app.session.active().unwrap().doc; + d.node(d.layers[0].id).unwrap().children().unwrap().iter().map(|n| n.id).collect::>() + }; + let square = c[1] + vec2(TARGET_X - SQUARE_X, 0.0); + only_the_primary_button_drags(&mut app, &ctx, (square, egui::pos2(150.0, c[0].y)), kids, "the selected-art square"); + + // Down the eye and the lock columns, from the group's row to a rectangle's. + for (x, what) in [(12.0, "the eye column"), (37.0, "the lock column")] { + let (mut app, [_, _, g, b, a, _]) = nested(); + let ctx = egui::Context::default(); + let c = rows(&mut app, &ctx); + let marks = move |app: &VectorcraftApp| [g, b, a].map(|id| (node(app, id).visible, node(app, id).locked)); + only_the_primary_button_drags(&mut app, &ctx, (egui::pos2(x, c[2].y), egui::pos2(x, c[4].y)), marks, what); + } + + // A target circle, onto another row's circle. + let (mut app, _, [a, b]) = two_rects(); + app.session.execute("transparency.set", &json!({"ids": [a.0], "opacity": 30})).unwrap(); + let ctx = egui::Context::default(); + let c = rows(&mut app, &ctx); + let opacities = move |app: &VectorcraftApp| [a, b].map(|id| node(app, id).opacity); + only_the_primary_button_drags(&mut app, &ctx, (c[2], c[1]), opacities, "a target circle"); + + // A secondary click on a row opens its menu. + let (mut app, _, _) = two_rects(); + let ctx = egui::Context::default(); + let c = frame(&mut app, &ctx, vec![], false).0; + let at = egui::pos2(150.0, c[0].y); + let right = egui::PointerButton::Secondary; + for e in [egui::Event::PointerMoved(at), button_by(at, true, right), button_by(at, false, right)] { + frame(&mut app, &ctx, vec![e], false); + } + assert_eq!(texts(&mut app, &ctx, vec![], "Collect for Export"), 1, "the row menu is open"); +} + #[test] fn the_panel_menu_lists_every_operation() { let (mut app, _) = nested(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..d103799dc1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -822,6 +822,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - These dialog sliders, each beside a number field, respond to the keyboard: those of the effect, tool option, brush and transform dialogs, the Perspective Grid, the 3D dialogs, and the flattener options, which the Flattener Preview panel also shows. A click, or Tab, gives a slider the keyboard focus, and an outline shows it. Then → and ↑ step the field's number up to the next whole number and ← and ↓ step it down to the next whole number, Shift with an arrow and Page Up and Page Down step it by ten, and Home and End set the slider's first and last value; no step goes past either end. Screen readers' increment and decrement actions step the slider as → and ← do. The Color panel's, Swatch Options', the Navigator's and the Character panel's sliders, and the Color Picker's channel slider, respond only to the pointer. The keys follow the WAI-ARIA slider pattern (); the reference app's help does not describe keys on a dialog's slider, so the step sizes are VectorCraft's own. - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). + - In the Layers panel only the primary mouse button drags. A drag with the right or middle button on a row, a guide row, the selection square, the eye or lock column or a target circle moves, copies, hides, locks and transfers nothing and records no undo step, and a right-click still opens the row's menu (`panels::layers` test `only_the_primary_button_drags_rows_guides_art_columns_and_appearances`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - A floating panel's OS window hides while a tool group's flyout or the All Tools drawer is open in the main window, as it does under a modal dialog or a menu, and shows again when the flyout or the drawer closes (#5813; `a_tool_flyout_hides_the_palette_windows_until_it_closes`). - On the desktop a floating panel in an OS window stores the monitor it is on and reopens there at the next launch. When that monitor is no longer connected, the panel opens on the main window's monitor at the same offset (#5582). Outside macOS, a desktop whose monitors have different scale factors keeps opening floating panels on the main window's monitor. Test: `panel_docking::native_tests::a_floating_panel_reopens_on_the_monitor_where_it_was_left`. From 93155b0e3fab41dca7ddf7ac70109d0809771274 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 17:40:41 -0800 Subject: [PATCH 16/22] Recolor Artwork: the Color Theme Picker keeps symbol art and clipped image reads within their limits The symbol instances the picker reads now share one limit of 16,000,000 placed objects, charged through charge_symbol_art and counted as the renderer counts them, so deeply nested or heavily repeated instances stop there. An instance whose art would pass the limit is left out with its own paints, as the canvas draws none of it. Pattern tiles carry the count in and out, and their art is read one level deeper than the object the pattern fills, so a chain of patterns shares the depth limit with the art around it; a pattern cut short there is read again where it is used less deep. The pixels read inside clip groups are now tested against at most 4,000,000 clipping-path elements in all. The clipped reads of one image share the work, a read outside clip groups keeps its own pixel budget, and when the work left can't test a pixel of every clipped read, the reads that fit are kept in order and the rest are left out. ImageBlob::sample_pixels takes a pixel budget for each pick. --- crates/doc/src/pixels.rs | 26 ++- crates/engine/src/cmd/recolor.rs | 387 +++++++++++++++++++++++++++---- docs/mcp.md | 4 +- 3 files changed, 360 insertions(+), 57 deletions(-) diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 7edf64eb5e..0812095826 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -11,9 +11,9 @@ use crate::ImageBlob; /// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB. pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; -/// A box of an image to sample, in 0 to 1 units of its width and height (`None`: all of it), and -/// the pixel centres to take from it ([`ImageBlob::sample_pixels`]). -pub type PixelPick<'a> = (Option<[f64; 4]>, &'a dyn Fn(f64, f64) -> bool); +/// A box of an image to sample, in 0 to 1 units of its width and height (`None`: all of it), the +/// pixels to sample from it at most, and the pixel centres to take ([`ImageBlob::sample_pixels`]). +pub type PixelPick<'a> = (Option<[f64; 4]>, usize, &'a dyn Fn(f64, f64) -> bool); /// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded, would take /// more than `max` bytes as RGBA, or need more than `max` bytes to decode. The size is read from @@ -65,13 +65,13 @@ impl ImageBlob { Some(Self::png(png)) } - /// For each of `picks`, at most `budget` of the image's pixels (at least one), spread evenly + /// For each of `picks`, at most its budget of the image's pixels (at least one), spread evenly /// in rows and columns over the pick's box (`[u0, v0, u1, v1]`, where 0 to 1 spans the /// image's width and height from the top left; clipped to the image; `None`: the whole image), /// as straight RGBA. Only pixels whose centres the pick's function takes (in the same 0 to 1 - /// units) are given. The image is decoded once for all the picks. `None` when it can't be - /// decoded within [`MAX_RASTER_ALLOC`]. - pub fn sample_pixels(&self, picks: &[PixelPick<'_>], budget: usize) -> Option>> { + /// units) are given, and the function is called once for each pixel sampled. The image is + /// decoded once for all the picks. `None` when it can't be decoded within [`MAX_RASTER_ALLOC`]. + pub fn sample_pixels(&self, picks: &[PixelPick<'_>]) -> Option>> { let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?; let (w, h) = img.dimensions(); let (fw, fh) = (f64::from(w), f64::from(h)); @@ -79,7 +79,7 @@ impl ImageBlob { let (a, b) = (a.min(b), a.max(b)); ((a * n).floor().clamp(0.0, n) as u32, (b * n).ceil().clamp(0.0, n) as u32) }; - let sample = |area: Option<[f64; 4]>, keep: &dyn Fn(f64, f64) -> bool| { + let sample = |area: Option<[f64; 4]>, budget: usize, keep: &dyn Fn(f64, f64) -> bool| { let [u0, v0, u1, v1] = area.unwrap_or([0.0, 0.0, 1.0, 1.0]); let (x0, x1) = span(u0, u1, fw); let (y0, y1) = span(v0, v1, fh); @@ -105,7 +105,7 @@ impl ImageBlob { } out }; - Some(picks.iter().map(|(area, keep)| sample(*area, *keep)).collect()) + Some(picks.iter().map(|(area, budget, keep)| sample(*area, *budget, *keep)).collect()) } /// The image as a grayscale PNG: `f` gives each pixel's gray level from its (straight) RGB, and @@ -331,7 +331,7 @@ mod tests { let (r, b) = ([255, 0, 0, 255], [0, 0, 255, 255]); let img = blob(&[r, r, b, b, r, r, b, b], 4, 2); let any = |_: f64, _: f64| true; - let one = |b: &ImageBlob, area: Option<[f64; 4]>, budget: usize| b.sample_pixels(&[(area, &any)], budget).unwrap().remove(0); + let one = |b: &ImageBlob, area: Option<[f64; 4]>, budget: usize| b.sample_pixels(&[(area, budget, &any)]).unwrap().remove(0); assert_eq!(one(&img, None, 100).len(), 8, "the budget covers every pixel"); assert_eq!(one(&img, Some([0.5, 0.0, 1.0, 1.0]), 100), vec![b; 4], "the right half"); assert_eq!(one(&img, Some([1.0, 1.0, 0.5, 0.0]), 100), vec![b; 4], "a box given backwards"); @@ -339,13 +339,15 @@ mod tests { assert_eq!(one(&img, None, 2).len(), 2, "every second pixel of every second row"); assert_eq!(one(&img, None, 0), vec![r], "a budget of 0 counts as 1"); let left = |u: f64, _: f64| u < 0.5; - let both = img.sample_pixels(&[(None, &left), (Some([0.5, 0.0, 1.0, 1.0]), &any)], 100).unwrap(); + let both = img.sample_pixels(&[(None, 100, &left), (Some([0.5, 0.0, 1.0, 1.0]), 100, &any)]).unwrap(); assert_eq!(both, vec![vec![r; 4], vec![b; 4]], "several picks, each with the centres its function takes"); + let sizes: Vec = img.sample_pixels(&[(None, 2, &any), (None, 100, &any)]).unwrap().iter().map(Vec::len).collect(); + assert_eq!(sizes, [2, 8], "each pick keeps to its own budget"); // A narrow image keeps to the budget too. let tall = blob(&vec![r; 5000], 1, 5000); let n = one(&tall, None, 100).len(); assert!((50..=100).contains(&n), "{n} pixels"); - assert!(ImageBlob::png(b"not a png".to_vec()).sample_pixels(&[(None, &any)], 100).is_none()); + assert!(ImageBlob::png(b"not a png".to_vec()).sample_pixels(&[(None, 100, &any)]).is_none()); } /// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 34091017e3..931bcce681 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Color Theme Picker", [], None, - "{picks: [{at: [x, y]} (a click: the object a Selection tool click there takes, with everything inside it) | {rect: [x0, y0, x1, y1]} (a marquee: every painted object it touches, and the part of each image inside it)] (1 to 256, in document points), colors?: 5 (1 to 64)} → {colors: [colour keys, the most used first], objects: the number of objects read} the colour palette of the art picked, for Recolor Artwork's Color Theme Picker: visible fills and strokes, gradient stops, text, mesh points, pattern tiles (with the patterns they use), the art of symbol instances (placed through their 9-slice guides), the colorize colour of an image it applies to and other images' pixels (at most 65,536 pixels of each pick of an image, spread evenly, and 1,048,576 pixels of at most 64 images in all; pixels less than half opaque and images that can't be decoded are left out). In a clip group, or under an opacity mask that clips, only what shows inside the clipping path or the mask art is read; a blend, envelope or repeat a marquee touches is read whole. Hidden or fully transparent objects and fill or stroke items are left out; locked objects are read. Each object read weighs the same, shared among its colours (an image's among its pixels; the vector art a symbol instance places counts as one object, each image it places on its own); similar colours merge (k-means in Lab) into at most `colors`, each shown by its most used colour in its own model. Picks that touch no art give no colours. Changes nothing", + "{picks: [{at: [x, y]} (a click: the object a Selection tool click there takes, with everything inside it) | {rect: [x0, y0, x1, y1]} (a marquee: every painted object it touches, and the part of each image inside it)] (1 to 256, in document points), colors?: 5 (1 to 64)} → {colors: [colour keys, the most used first], objects: the number of objects read} the colour palette of the art picked, for Recolor Artwork's Color Theme Picker: visible fills and strokes, gradient stops, text, mesh points, pattern tiles (with the patterns they use), the art of symbol instances (placed through their 9-slice guides), the colorize colour of an image it applies to and other images' pixels (at most 65,536 pixels of each pick of an image, spread evenly, and 1,048,576 pixels of at most 64 images in all; pixels less than half opaque and images that can't be decoded are left out). In a clip group, or under an opacity mask that clips, only what shows inside the clipping path or the mask art is read; a blend, envelope or repeat a marquee touches is read whole. The symbol instances read place at most 16,000,000 objects in all (an instance whose art would pass that is left out), and the pixels read inside clip groups are tested against at most 4,000,000 clipping-path elements in all (the picks of an image inside clip groups that would pass that are left out). Hidden or fully transparent objects and fill or stroke items are left out; locked objects are read. Each object read weighs the same, shared among its colours (an image's among its pixels; the vector art a symbol instance places counts as one object, each image it places on its own); similar colours merge (k-means in Lab) into at most `colors`, each shown by its most used colour in its own model. Picks that touch no art give no colours. Changes nothing", has_doc, pick_theme ), @@ -75,8 +75,8 @@ const MAX_THEME_IMAGES: usize = 64; const MAX_THEME_READS: usize = 4096; /// The stroke segments the opacity masks one pick reads are outlined along, at most. const MASK_OUTLINE_SEGMENTS: usize = 20_000; -/// The point-in-region tests one pick makes to find whether outlines show through clip groups, -/// and the clipping-path elements the pixels of one image are tested against, at most. +/// The point-in-region tests the picks make to find whether outlines show through clip groups, +/// and the clipping-path elements the pixels of their images are tested against, at most. const THEME_WORK: usize = 4_000_000; /// The picks one `recolor.pickTheme` takes, and the colours it gives, at most. const MAX_PICKS: usize = 256; @@ -126,6 +126,10 @@ struct ImageRead<'a> { colorize: Option>, } +/// A pattern's colours, the image reads of its tiles, and the depth its tiles were read at when +/// [`MAX_THEME_DEPTH`] cut them short. +type PatternRead<'a> = (Vec, Vec>, Option); + /// The colours the Color Theme Picker has read: each object weighs 1, shared among its colours. struct Theme<'a> { d: &'a Document, @@ -138,11 +142,17 @@ struct Theme<'a> { /// What the clip groups being read show, in document space: the union of these regions /// (none: nothing shows); `None` outside clip groups. clip: Option>, - /// The colours of the patterns read so far, and the patterns whose tiles are being read. - pattern_colors: HashMap, Vec>)>, + /// The colours of the patterns read so far (with the depth their tiles were read at when + /// [`MAX_THEME_DEPTH`] cut them short), and the patterns whose tiles are being read. + pattern_colors: HashMap>, pattern_stack: Vec, + /// Whether art was left out for passing [`MAX_THEME_DEPTH`]. + cut: bool, /// The region tests [`Self::shows`] has made. work: std::cell::Cell, + /// The objects the symbol instances read have placed, of [`vectorcraft_doc::MAX_SYMBOL_NODES`] + /// ([`vectorcraft_doc::charge_symbol_art`]). + symbol_nodes: u64, /// The object isolated in isolation mode. scope: Option, /// The stroke segments opacity masks may still be outlined along ([`mask_regions`]). @@ -170,8 +180,10 @@ impl<'a> Theme<'a> { blobs: std::collections::HashSet::new(), clip: None, pattern_colors: HashMap::new(), + cut: false, pattern_stack: vec![], work: std::cell::Cell::new(0), + symbol_nodes: 0, scope: None, mask_budget: MASK_OUTLINE_SEGMENTS, objects: 0, @@ -192,8 +204,10 @@ impl<'a> Theme<'a> { /// many there are, and the cells of all the reads add up, so the images give at most 16³ /// colours. Images that can't be decoded give none. The first [`MAX_THEME_IMAGES`] images /// are read, and all the reads share [`THEME_TOTAL_PIXELS`] pixels (each read at least one, at - /// most [`THEME_PIXELS`]). - fn read_images(&mut self) { + /// most [`THEME_PIXELS`]). The pixels read inside clip groups are tested against + /// [`THEME_WORK`] clipping-path elements in all; the reads of an image inside clip groups + /// that would pass it are left out. Gives the clipping-path elements tested. + fn read_images(&mut self) -> usize { let reads = std::mem::take(&mut self.images); // The reads of each image, in the order its first read came. let mut by_image: Vec<(*const vectorcraft_doc::ImageBlob, Vec<&ImageRead>)> = vec![]; @@ -208,15 +222,42 @@ impl<'a> Theme<'a> { let count: usize = by_image.iter().map(|(_, g)| g.len()).sum(); let budget = (THEME_TOTAL_PIXELS / count.max(1)).clamp(1, THEME_PIXELS); let mut pixels: HashMap<[u8; 3], ([f64; 3], f64)> = HashMap::new(); - for (_, group) in by_image { + // The clipping-path elements the pixels read have been tested against. + let tested = std::cell::Cell::new(0usize); + let elements = + |r: &ImageRead| r.clip.as_ref().map_or(0, |rs| rs.iter().map(|(bp, _)| bp.elements().len().max(1)).fold(0, usize::saturating_add)); + for (_, mut group) in by_image { + // Each pixel sampled for a read inside clip groups is tested against its clipping + // regions: the clipped reads of one image take at most `clipped` pixels each, so all + // of them keep within the work left, and the other reads take `budget`. When the work + // left can't test one pixel of each, the clipped reads are kept in order while their + // pixel fits, and the rest are left out. + let left = THEME_WORK.saturating_sub(tested.get()); + if group.iter().map(|r| elements(r)).fold(0, usize::saturating_add) > left { + let mut room = left; + group.retain(|r| { + let c = elements(r); + let fits = c <= room; + room = room.saturating_sub(if fits { c } else { 0 }); + fits + }); + } + let cost = group.iter().map(|r| elements(r)).fold(0, usize::saturating_add); + let clipped = left.checked_div(cost).map_or(budget, |w| budget.min(w)); + if group.is_empty() { + continue; + } let keeps: Vec bool + '_>> = group .iter() .map(|r| { - let (xf, (w, h), area, clip) = (r.xf, r.size, r.area, &r.clip); + let (xf, (w, h), area, clip, cost, tested) = (r.xf, r.size, r.area, &r.clip, elements(r), &tested); Box::new(move |u: f64, v: f64| { let p = xf * Point::new(u * w, v * h); area.is_none_or(|a| a.contains(p)) - && clip.as_ref().is_none_or(|rs| rs.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))) + && clip.as_ref().is_none_or(|rs| { + tested.set(tested.get().saturating_add(cost)); + rs.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)) + }) }) as Box bool + '_> }) .collect(); @@ -241,15 +282,10 @@ impl<'a> Theme<'a> { let high = |f: fn(&Point) -> f64| uv.iter().map(f).fold(f64::NEG_INFINITY, f64::max); [low(|p| p.x) / w, low(|p| p.y) / h, high(|p| p.x) / w, high(|p| p.y) / h] }); - (box_uv, keep.as_ref()) + (box_uv, if r.clip.is_some() { clipped } else { budget }, keep.as_ref()) }) .collect(); - // Each sampled pixel inside a clip group is tested against the clipping regions: the - // pixels one image gives keep that work within THEME_WORK path elements. - let elements = - group.iter().map(|r| r.clip.as_ref().map_or(0, |rs| rs.iter().map(|(bp, _)| bp.elements().len()).sum::())).max().unwrap_or(0); - let budget = THEME_WORK.checked_div(elements).map_or(budget, |w| budget.min(w.max(1))); - let Some(each) = group[0].blob.sample_pixels(&picks, budget) else { continue }; + let Some(each) = group[0].blob.sample_pixels(&picks) else { continue }; for (px, read) in each.into_iter().zip(&group) { if let Some(colors) = &read.colorize { // Ink: an opaque pixel darker than white takes the colorize paint. @@ -289,6 +325,7 @@ impl<'a> Theme<'a> { let [r, g, b] = sum.map(|s| (s / w).round().clamp(0.0, 255.0) as u8); self.samples.push((Color::rgb8(r, g, b), w as f32)); } + tested.get() } /// Read `n` and everything visible inside it: each image (its part inside `area` when given, @@ -316,8 +353,12 @@ impl<'a> Theme<'a> { /// [`Self::read`] without `n`'s opacity mask. fn read_unmasked(&mut self, n: &Node, area: Option, depth: usize) { + if depth > MAX_THEME_DEPTH { + self.cut = true; + return; + } // Hidden and fully transparent objects show nothing. - if depth > MAX_THEME_DEPTH || !n.visible || n.opacity <= 0.0 || matches!(n.kind, NodeKind::Path { guide: true, .. }) { + if !n.visible || n.opacity <= 0.0 || matches!(n.kind, NodeKind::Path { guide: true, .. }) { return; } let touches = area.is_none_or(|r| self.touches(n, r)); @@ -332,13 +373,13 @@ impl<'a> Theme<'a> { // other paints its appearance has (with effects that draw them) count as well. let colorized = n.extras().colorize.is_some() && blob.takes_colorize(); let own = if colorized || n.extras().colorize.is_none() { - self.own(n, true) + self.own(n, true, depth) } else { // A colorize paint the image does not take (a linked image's preview) // shows nothing. let mut plain = n.clone(); plain.extras_mut().colorize = None; - self.own(&plain, true) + self.own(&plain, true, depth) }; let colorize = if colorized { Some(own) @@ -357,31 +398,43 @@ impl<'a> Theme<'a> { && self.shows(n, area) && let Some(s) = self.d.symbols.iter().find(|s| s.name == *symbol) { - // The instance's own paints; its overrides show in its art. - let own = self.own(n, false); - self.add(own); // The art as the instance places it: through the symbol's 9-slice guides when - // it has them, else by the instance's transform. Art that would place too many - // objects is left out, as the renderer leaves it out. + // it has them, else by the instance's transform. All the instances read share + // one limit on the objects they place, counted as the renderer counts them: an + // instance whose art would pass it shows nothing and is left out. let dynamic = (!n.extras().symbol_overrides.is_empty()).then(|| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)); - if let Some(placed) = self.d.place_symbol_art(symbol, dynamic.as_ref().unwrap_or(&s.art), *xf) { - // The vector art counts as one object, each of its colours once, so an - // object the 9-slice guides cut into several copies does not count more. - // Each image placed is read on its own, as images are elsewhere. - let (samples, objects) = (self.samples.len(), self.objects); - self.read(&placed, None, depth + 1); - let added = self.samples.split_off(samples); - self.objects = objects; - let mut seen = std::collections::HashSet::new(); - let colors: Vec = added.into_iter().map(|(c, _)| c).filter(|c| seen.insert(ColorKey::of(c))).collect(); - self.add(colors); - } + let (d, art) = (self.d, dynamic.as_ref().unwrap_or(&s.art)); + let placed = match d.symbol_slice_map(symbol, *xf) { + Some(m) => vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, m.most_placed(art, d), || { + let placed = d.place_symbol_art(symbol, art, *xf); + let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); + (placed, n) + }), + None => { + let count = art.count() as u64; + vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, count, || (d.place_symbol_art(symbol, art, *xf), count)) + } + }; + let Some(Some(placed)) = placed else { return }; + // The instance's own paints; its overrides show in its art. + let own = self.own(n, false, depth); + self.add(own); + // The vector art counts as one object, each of its colours once, so an + // object the 9-slice guides cut into several copies does not count more. + // Each image placed is read on its own, as images are elsewhere. + let (samples, objects) = (self.samples.len(), self.objects); + self.read(&placed, None, depth + 1); + let added = self.samples.split_off(samples); + self.objects = objects; + let mut seen = std::collections::HashSet::new(); + let colors: Vec = added.into_iter().map(|(c, _)| c).filter(|c| seen.insert(ColorKey::of(c))).collect(); + self.add(colors); } return; } NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { if touches && self.shows(n, area) { - let own = self.own(n, true); + let own = self.own(n, true, depth); self.add(own); // The copies are drawn elsewhere than the objects lie: the clipping paths // around the live object, tested above, do not apply to the objects. @@ -396,7 +449,7 @@ impl<'a> Theme<'a> { _ => {} } if touches && self.shows(n, area) { - let own = self.own(n, true); + let own = self.own(n, true, depth); self.add(own); } // A compound path's members are its geometry: the compound paints them. @@ -767,13 +820,16 @@ impl<'a> Theme<'a> { /// document art is read (clip groups and symbols included; the tiles' images join this pick's /// image reads). Each pattern's colours /// are read once; a pattern whose tile uses itself, directly or through other patterns, adds - /// nothing more the second time. - fn own(&mut self, m: &Node, overrides: bool) -> Vec { + /// nothing more the second time. `depth` is `m`'s ([`Self::read`]): the tiles' art is read one + /// deeper, and a pattern whose tiles [`MAX_THEME_DEPTH`] cut short is read again when it is + /// used less deep. + fn own(&mut self, m: &Node, overrides: bool, depth: usize) -> Vec { let mut out = vec![]; let mut patterns: Vec = vec![]; paint_colors_of(m, overrides, &mut out, &mut patterns); for name in patterns { - if let Some((colors, reads)) = self.pattern_colors.get(&name) { + if let Some((colors, reads, cut)) = self.pattern_colors.get(&name).filter(|e| e.2.is_none_or(|at| depth >= at)) { + self.cut |= cut.is_some(); out.extend(colors); // The tiles' images count again for each use, within the pick's limits. let reads = reads.clone(); @@ -795,10 +851,11 @@ impl<'a> Theme<'a> { sub.blobs = std::mem::take(&mut self.blobs); // The tiles share this pick's limits on work too. sub.work.set(self.work.get()); + sub.symbol_nodes = self.symbol_nodes; sub.mask_budget = self.mask_budget; let before = sub.images.len(); for a in &def.art { - sub.read(a, None, 0); + sub.read(a, None, depth + 1); } sub.pattern_stack.pop(); self.pattern_stack = std::mem::take(&mut sub.pattern_stack); @@ -807,10 +864,12 @@ impl<'a> Theme<'a> { self.images = std::mem::take(&mut sub.images); self.blobs = std::mem::take(&mut sub.blobs); self.work.set(sub.work.get()); + self.symbol_nodes = sub.symbol_nodes; self.mask_budget = sub.mask_budget; + self.cut |= sub.cut; let colors: Vec = sub.samples.iter().map(|(c, _)| *c).collect(); out.extend(&colors); - self.pattern_colors.insert(name, (colors, reads)); + self.pattern_colors.insert(name, (colors, reads, sub.cut.then_some(depth))); } out } @@ -1529,3 +1588,243 @@ fn randomize(_: &mut Session, p: &Value) -> Result { .collect(); Ok(json!({ "map": map })) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vectorcraft_doc::{Appearance, ImageBlob, MAX_SYMBOL_NODES, PatternDef, Symbol}; + use vectorcraft_geom::{Affine, BezPath, FillRule, shapes}; + + use super::*; + + const RED: Color = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }; + const BLUE: Color = Color::Rgb { r: 0.0, g: 0.0, b: 1.0 }; + + /// A 10 pt square from (`x`, 0) painted `fill`. + fn square(d: &mut Document, x: f64, fill: Paint) -> Arc { + let p = shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)); + Arc::new(Node::path(d.alloc_id(), p, Appearance::basic(fill, Paint::None, 0.0))) + } + + /// Symbol `name` whose art is a group of `art`, and an instance of it. + fn symbol(d: &mut Document, name: &str, art: Vec>) -> Arc { + let group = Node::group(d.alloc_id(), art); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) }); + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })) + } + + fn has(t: &Theme, c: Color) -> bool { + t.samples.iter().any(|(s, _)| ColorKey::of(s) == ColorKey::of(&c)) + } + + /// The symbol instances read share one limit on the objects they place: with room for one + /// instance's art (a group and a square, 2 objects), the first instance is read and the + /// second, of another symbol, is left out. + #[test] + fn symbol_instances_share_one_limit_on_placed_objects() { + let mut d = Document::new(100.0, 100.0); + let red = square(&mut d, 0.0, Paint::solid(RED)); + let a = symbol(&mut d, "Red", vec![red]); + let blue = square(&mut d, 20.0, Paint::solid(BLUE)); + let b = symbol(&mut d, "Blue", vec![blue]); + let mut t = Theme::new(&d); + t.symbol_nodes = MAX_SYMBOL_NODES - 3; + t.read(&a, None, 0); + t.read(&b, None, 0); + assert!(has(&t, RED), "the first instance fits"); + assert!(!has(&t, BLUE), "the second does not"); + assert_eq!(t.symbol_nodes, MAX_SYMBOL_NODES - 1); + } + + /// An instance left out at the limit shows nothing, so its own paints are left out too. + #[test] + fn an_instance_past_the_limit_gives_none_of_its_own_paints() { + let mut d = Document::new(100.0, 100.0); + let red = square(&mut d, 0.0, Paint::solid(RED)); + let mut inst = (*symbol(&mut d, "Red", vec![red])).clone(); + inst.appearance = Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0).into(); + let mut t = Theme::new(&d); + t.read(&inst, None, 0); + assert!(has(&t, BLUE), "the instance's own fill, with room for its art"); + let mut t = Theme::new(&d); + t.symbol_nodes = MAX_SYMBOL_NODES - 1; + t.read(&inst, None, 0); + assert!(t.samples.is_empty() && t.objects == 0, "nothing past the limit: {:?}", t.samples); + } + + /// An instance without 9-slice guides is counted as the renderer counts it, without the art of + /// its opacity masks: a square with a mask of 6 objects places 2. + #[test] + fn an_instance_is_counted_without_its_mask_art() { + let mut d = Document::new(100.0, 100.0); + let leaf = square(&mut d, 0.0, Paint::solid(BLUE)); + let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(d.alloc_id(), vec![leaf; 5]), true); + mask.disabled = true; + let mut red = (*square(&mut d, 0.0, Paint::solid(RED))).clone(); + red.extras_mut().mask = Some(Box::new(mask)); + let inst = symbol(&mut d, "Masked", vec![Arc::new(red)]); + let mut t = Theme::new(&d); + t.symbol_nodes = MAX_SYMBOL_NODES - 3; + t.read(&inst, None, 0); + assert!(has(&t, RED)); + assert_eq!(t.symbol_nodes, MAX_SYMBOL_NODES - 1); + } + + /// Nested instances charge the limit at every level: a tree of four leaf instances (5 + /// objects) whose leaves place 2 objects each places 13 objects in all, and with 9 objects of + /// room it places the tree and two of its leaves. + #[test] + fn nested_symbol_instances_charge_every_level() { + let mut d = Document::new(100.0, 100.0); + let red = square(&mut d, 0.0, Paint::solid(RED)); + let leaf = symbol(&mut d, "Leaf", vec![red]); + let tree = symbol(&mut d, "Tree", vec![leaf; 4]); + let mut t = Theme::new(&d); + t.read(&tree, None, 0); + assert_eq!(t.symbol_nodes, 13); + let mut t = Theme::new(&d); + t.symbol_nodes = MAX_SYMBOL_NODES - 9; + t.read(&tree, None, 0); + assert_eq!(t.symbol_nodes, MAX_SYMBOL_NODES, "the tree and two leaves"); + assert!(has(&t, RED)); + } + + /// Patterns `P0` to `P{n}`: each tile a group holding a square filled with the next pattern, + /// the last a red square. Gives a square filled with `P0`. + fn pattern_chain(d: &mut Document, n: usize) -> Arc { + let fill = |i: usize| Paint::Pattern { pattern: format!("P{i}"), xf: Affine::IDENTITY }; + for i in 0..=n { + let inner = if i == n { square(d, 0.0, Paint::solid(RED)) } else { square(d, 0.0, fill(i + 1)) }; + let tile = Arc::new(Node::group(d.alloc_id(), vec![inner])); + d.patterns.push(PatternDef::new(&format!("P{i}"), vec![tile])); + } + square(d, 0.0, fill(0)) + } + + /// Pattern tiles are read one level deeper than the object their pattern fills, so a chain of + /// patterns shares one limit on depth with the art around it: the end of a chain of 50 is + /// read, the end of a chain of 200 (two levels for each tile) is not. + #[test] + fn pattern_tiles_share_one_depth_limit() { + let mut d = Document::new(100.0, 100.0); + let short = pattern_chain(&mut d, 50); + let mut t = Theme::new(&d); + t.read(&short, None, 0); + assert!(has(&t, RED), "a chain of 50"); + let mut d = Document::new(100.0, 100.0); + let long = pattern_chain(&mut d, 200); + let p100 = square(&mut d, 0.0, Paint::Pattern { pattern: "P100".into(), xf: Affine::IDENTITY }); + let mut t = Theme::new(&d); + t.read(&long, None, 0); + assert!(!has(&t, RED), "a chain of 200 passes {MAX_THEME_DEPTH} levels"); + // The patterns cut short there are read again when used less deep: from P100 the end is + // 100 patterns away. + t.read(&p100, None, 0); + assert!(has(&t, RED), "P100 read again at the top"); + } + + /// A pattern tile's symbol instances charge the same limit as the art around it. + #[test] + fn pattern_tiles_charge_the_symbol_limit() { + let mut d = Document::new(100.0, 100.0); + let red = square(&mut d, 0.0, Paint::solid(RED)); + let inst = symbol(&mut d, "Red", vec![red]); + d.patterns.push(PatternDef::new("Dots", vec![inst])); + let art = square(&mut d, 0.0, Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }); + let mut t = Theme::new(&d); + t.read(&art, None, 0); + assert!(has(&t, RED)); + assert_eq!(t.symbol_nodes, 2, "the tile's instance placed a group and a square"); + let mut t = Theme::new(&d); + t.symbol_nodes = MAX_SYMBOL_NODES - 1; + t.read(&art, None, 0); + assert!(!has(&t, RED), "no room for the tile's instance"); + } + + /// A `size` px image of one color `rgba`. + fn blob(rgba: [u8; 4], size: u32) -> ImageBlob { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(size, size, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + ImageBlob::png(png) + } + + /// A read of all of `blob`, placed at the origin 1 pt a pixel, inside a clipping path over + /// it of `elements` path elements. + fn clipped(blob: &ImageBlob, size: f64, elements: usize) -> ImageRead<'_> { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + let steps = elements.saturating_sub(4).max(1); + for i in 1..=steps { + bp.line_to((size * i as f64 / steps as f64, 0.0)); + } + bp.line_to((size, size)); + bp.line_to((0.0, size)); + bp.close_path(); + ImageRead { blob, xf: Affine::IDENTITY, size: (size, size), area: None, clip: Some(vec![(bp, FillRule::NonZero)]), colorize: None } + } + + /// The reads of one image inside clip groups share the work limit: three reads of a 64 px + /// image, each inside a clipping path of 3,000 elements, test at most [`THEME_WORK`] elements + /// in all and still read the image. + #[test] + fn reads_of_one_image_share_the_clip_work_limit() { + let d = Document::new(100.0, 100.0); + let red = blob([255, 0, 0, 255], 64); + let mut t = Theme::new(&d); + t.images = (0..3).map(|_| clipped(&red, 64.0, 3000)).collect(); + let tested = t.read_images(); + assert!(tested > 0 && tested <= THEME_WORK, "{tested} elements tested"); + assert!(has(&t, RED)); + } + + /// A read of an image outside clip groups keeps its own pixel budget when another read of the + /// same image inside a clip group gets only two pixels: the blue 8 px block in the corner of a + /// red 64 px image is still read. + #[test] + fn unclipped_reads_keep_their_budget_beside_clipped_reads() { + let d = Document::new(100.0, 100.0); + let red = blob([255, 0, 0, 255], 64); + let mut png = Vec::new(); + image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba(if x >= 56 && y >= 56 { [0, 0, 255, 255] } else { [255, 0, 0, 255] })) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let corner = ImageBlob::png(png); + let mut t = Theme::new(&d); + let whole = ImageRead { clip: None, ..clipped(&corner, 64.0, 4) }; + // The red read leaves 6,400 elements of work; 3,200 elements a pixel leave two pixels. + t.images = vec![clipped(&red, 64.0, 3900), whole, clipped(&corner, 64.0, 3200)]; + let tested = t.read_images(); + assert!(tested <= THEME_WORK, "{tested} elements tested"); + assert!(has(&t, BLUE), "the read outside clip groups samples the whole image"); + } + + /// When the work left can't test a pixel of every clipped read of an image, the reads that fit + /// are still read: with 6,400 elements left, a read of a blue image inside a clipping path + /// of 5,000 elements is read beside one of 2,000 elements, which is left out. + #[test] + fn clipped_reads_that_fit_the_work_left_are_read() { + let d = Document::new(100.0, 100.0); + let (red, blue) = (blob([255, 0, 0, 255], 64), blob([0, 0, 255, 255], 64)); + let mut t = Theme::new(&d); + t.images = vec![clipped(&red, 64.0, 3900), clipped(&blue, 64.0, 5000), clipped(&blue, 64.0, 2000)]; + let tested = t.read_images(); + assert!(tested <= THEME_WORK, "{tested} elements tested"); + assert!(has(&t, BLUE), "the read that fits is read"); + } + + /// Past the work limit, the reads of an image inside clip groups are left out: a red image + /// inside a clipping path of 3,900 elements takes nearly all of it, and a blue image after it, + /// inside one of 100,000 elements, is not read. + #[test] + fn clipped_image_reads_past_the_work_limit_are_left_out() { + let d = Document::new(100.0, 100.0); + let (red, blue) = (blob([255, 0, 0, 255], 64), blob([0, 0, 255, 255], 64)); + let mut t = Theme::new(&d); + t.images = vec![clipped(&red, 64.0, 3900), clipped(&blue, 64.0, 100_000)]; + let tested = t.read_images(); + assert!(tested <= THEME_WORK, "{tested} elements tested"); + assert!(has(&t, RED)); + assert!(!has(&t, BLUE), "the blue image is past the limit"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index bf159f5f00..6e5d1e80fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3678,7 +3678,9 @@ colorize color of an image it applies to and the pixels of other images: at most image, spread evenly, and 1,048,576 pixels of at most 64 images in all, leaving out pixels less than half opaque and images it can't decode; each image is decoded once for all its picks. In a clip group, or under an opacity mask that clips, it reads only what shows inside the clipping path or the mask art, and a blend, envelope or repeat that a -marquee touches is read whole. Hidden or fully transparent objects and fill or stroke items are left out, and locked +marquee touches is read whole. The symbol instances read place at most 16,000,000 objects in all, as on the canvas, +and an instance whose art would pass that is left out. The pixels read inside clip groups are tested against at most +4,000,000 clipping-path elements in all; the picks of an image inside clip groups that would pass that are left out. Hidden or fully transparent objects and fill or stroke items are left out, and locked objects are read. As VectorCraft's own rule, each object read weighs the same, shared among its colors (an image's among its pixels; the vector art a symbol instance places counts as one object, and each image it places counts on its own), and similar colors merge (k-means in Lab) into at most `colors` (1 to 64, default 5), each shown by its From 914607a72c2bd2b4cc1e6912f16bfd5cb2b95172 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:52:15 -0500 Subject: [PATCH 17/22] Swatches list (#1638): parity and roadmap revision rows --- docs/roadmap.md | 3 ++- docs/target-app-parity.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c78d258c9..2e38263867 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. | | 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..9a6549c4ca 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. | | 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | From fe1d56f339562700ed66560ab0ba49ad2ccac7b5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:06:23 -0800 Subject: [PATCH 18/22] Mesh: adding a line at a mesh point records no undo step object.mesh.addLine at a point that is already a mesh point (a corner, a point on an edge or inside) adds neither a row nor a column, yet it recorded an undo step. The lines are now added to a copy of the object first: when the grid stays the same, including a mesh envelope stored without handles, the command returns the point's index and records nothing. A new color at the point is still one undo step. --- crates/engine/src/cmd/live/commands.rs | 2 +- crates/engine/src/cmd/live/mesh.rs | 42 ++++++++++++++++++++++---- crates/engine/src/tests/envelope.rs | 19 ++++++++++++ crates/engine/src/tests/live.rs | 19 ++++++++++++ docs/mcp.md | 3 +- 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 0db3987e29..2a427be32d 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -290,7 +290,7 @@ pub fn specs() -> Vec { "Add Mesh Line", [], None, - "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge → {index}", + "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes it → {index}", has_doc, mesh_add_line ), diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 187d2149f2..3a4d1851a0 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -122,9 +122,14 @@ fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: b /// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a mesh envelope's grid too (its /// points and handles are written back: editing fills in the handles). fn edit_grid(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { - match d.node_mut(id).map(|n| &mut n.kind) { - Some(NodeKind::Mesh(m)) => f(m), - Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { + edit_kind(&mut d.node_mut(id).ok_or(EngineError::NoNode(id))?.kind, cmd, envelopes, f) +} + +/// [`edit_grid`] on a node's kind. +fn edit_kind(k: &mut NodeKind, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + match k { + NodeKind::Mesh(m) => f(m), + NodeKind::Envelope { kind, .. } if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { return Err(bad(cmd, "not a gradient mesh or mesh envelope")); }; @@ -135,8 +140,7 @@ fn edit_grid(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: imp *handles = m.points.iter().map(|q| q.handles).collect(); Ok(r) } - Some(_) => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), - None => Err(EngineError::NoNode(id)), + _ => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), } } @@ -281,12 +285,38 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["tolerance"], C)?; let tol = f64_or(p, "tolerance", 0.0).max(0.0); let color = color_param(p, "color", C)?.map(|c| crate::cmd::paint::new_color(s, p, c)); - let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| { + let add = move |m: &mut GradientMesh| { let i = m.add_lines_at(q, tol).ok_or_else(|| bad(C, "point is outside the mesh"))?; if let Some(c) = color { m.points[i].color = c; } Ok(i) + }; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; + if s.script_running() { + return Err(EngineError::Busy); + } + // The lines are added to a copy of the object. A point that is already a mesh point adds none: + // when its color stays too, nothing changes and no undo step is recorded. The grid is compared, + // not the node: a mesh envelope stored without handles takes them in any edit. + let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.kind.clone(); + let mut kind = orig.clone(); + let (i, same) = edit_kind(&mut kind, C, true, |m| { + let before = m.clone(); + let i = add(m)?; + Ok((i, *m == before)) + })?; + if same { + return Ok(json!({ "index": i })); + } + let index = s.edit("Add Mesh Line", move |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + // Settling typed values before the edit may have changed the object: add the lines again. + if n.kind != orig { + return edit_kind(&mut n.kind, C, true, add); + } + n.kind = kind; + Ok(i) })?; Ok(json!({ "index": index })) } diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index 4ffd7a361a..4a9e6395c3 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -123,6 +123,25 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() { assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err()); } +/// `object.mesh.addLine` at a point a mesh envelope already has (one stored without handles) adds +/// no line and records no undo step; a point between its lines adds them as one step. +#[test] +fn mesh_envelope_add_line_at_a_mesh_point_records_no_undo_step() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2})); + let before = kind(&s, e); + assert!(matches!(&before, EnvelopeKind::Mesh { handles, .. } if handles.is_empty()), "{before:?}"); + let undo_len = |s: &Session| s.doc().unwrap().history.undo.len(); + let n = undo_len(&s); + for (x, y) in [(100.0, 100.0), (300.0, 200.0), (200.0, 150.0), (300.0, 150.0)] { + s.execute("object.mesh.addLine", &json!({"id": e.0, "x": x, "y": y})).unwrap(); + assert_eq!((kind(&s, e), undo_len(&s)), (before.clone(), n), "({x}, {y}): nothing changed"); + } + s.execute("object.mesh.addLine", &json!({"id": e.0, "x": 150, "y": 125})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 3, cols: 3, .. }), "{:?}", kind(&s, e)); + assert_eq!(undo_len(&s), n + 1); +} + /// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps /// it through Reset with Warp. One saved before the version existed keeps the earlier map when its /// warp or its options change. diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 4556e6f75f..7beb512ed0 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -1206,6 +1206,25 @@ fn mesh_add_line_exactly_on_the_edge_needs_no_tolerance() { assert_eq!(undo_len(&s), n, "nothing changed"); } +/// `object.mesh.addLine` at a point that is already a mesh point (a corner, a point on an edge or +/// inside) adds no line and records no undo step; with a new `color` there it is one step. +#[test] +fn mesh_add_line_at_a_mesh_point_records_no_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let (before, n) = (mesh_of(&s, a), undo_len(&s)); + for (x, y) in [(0.0, 0.0), (100.0, 100.0), (50.0, 100.0), (100.0, 50.0), (50.0, 50.0)] { + let r = s.execute("object.mesh.addLine", &json!({"id": a.0, "x": x, "y": y})).unwrap(); + let p = mesh_of(&s, a).points[r["index"].as_u64().unwrap() as usize].p; + assert!(p.distance(Point::new(x, y)) < 0.5, "({x}, {y}): {p:?}"); + assert_eq!((mesh_of(&s, a), undo_len(&s)), (before.clone(), n), "({x}, {y}): nothing changed"); + } + s.execute("object.mesh.addLine", &json!({"id": a.0, "x": 100, "y": 100, "color": "#00ff00"})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "a new color is a step"); +} + /// A mesh point `index`, and `rows`, `cols` or `columns` for Create Gradient Mesh, that is not a /// whole number is an error that changes nothing; 4.0 names point 4. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 30fe6efc65..a75646d618 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9627,7 +9627,8 @@ right/left/down/up; none stored means the smooth mesh through the points, as old `object.mesh.deletePoint` edit them like a gradient mesh's (one undo step each). On the canvas a selected envelope shows its mesh; the Mesh tool and Direct Selection drag its points and the clicked point's handles, and the Mesh tool adds a row and a column where it clicks inside. `object.mesh.addLine`, and `object.mesh.create` with `at`, take a point exactly on -the mesh's outline (within a millionth of a point) on any side without `tolerance`; a point outside needs `tolerance`. `press_key` Escape during a drag of a point or handle of a mesh +the mesh's outline (within a millionth of a point) on any side without `tolerance`; a point outside needs `tolerance`. `object.mesh.addLine` at a point that is already a mesh +point adds no line and records no undo step unless its `color` changes the point. `press_key` Escape during a drag of a point or handle of a mesh envelope or a gradient mesh cancels it: the point goes back and no undo step is added. The Control bar shows Edit Envelope / Edit Contents, the warp's style, orientation, bend and distortions (or the mesh's rows and columns), Reset (an unbent warp, a flat mesh) and Envelope Options. From 7380b2475df87fa1d1d2273ef45c1d740a8ff262 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:23:31 -0800 Subject: [PATCH 19/22] Mesh: a color at a mesh envelope's point records no undo step A mesh envelope's points carry no color, so object.mesh.addLine with a color at a point the envelope already has changes nothing. It now compares only the envelope's points and handles and records no undo step. The command text and docs/mcp.md say so. --- crates/engine/src/cmd/live/commands.rs | 2 +- crates/engine/src/cmd/live/mesh.rs | 7 +++++-- crates/engine/src/tests/envelope.rs | 6 +++++- docs/mcp.md | 3 ++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 2a427be32d..0c835c7b5d 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -290,7 +290,7 @@ pub fn specs() -> Vec { "Add Mesh Line", [], None, - "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes it → {index}", + "{id, x, y, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), tolerance?: pt (0)} add a row and a column through (x, y), inside or on the edge, to a gradient mesh (new point in `color`) or a mesh envelope; a point outside within `tolerance` of the edge adds them through the nearest point of the edge; at a point that is already a mesh point it adds no line, and records no undo step unless `color` changes a gradient mesh's point (a mesh envelope's points have no color) → {index}", has_doc, mesh_add_line ), diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 3a4d1851a0..2290b56af4 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -298,13 +298,16 @@ pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { } // The lines are added to a copy of the object. A point that is already a mesh point adds none: // when its color stays too, nothing changes and no undo step is recorded. The grid is compared, - // not the node: a mesh envelope stored without handles takes them in any edit. + // not the node: a mesh envelope stored without handles takes them in any edit, and keeps no + // colors, so only its points and handles count. let orig = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.kind.clone(); + let colored = matches!(orig, NodeKind::Mesh(_)); let mut kind = orig.clone(); let (i, same) = edit_kind(&mut kind, C, true, |m| { let before = m.clone(); let i = add(m)?; - Ok((i, *m == before)) + let shape = |g: &GradientMesh| (g.rows, g.cols, g.points.iter().map(|q| (q.p, q.handles)).collect::>()); + Ok((i, if colored { *m == before } else { shape(m) == shape(&before) })) })?; if same { return Ok(json!({ "index": i })); diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index 4a9e6395c3..9d8999e388 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -124,7 +124,8 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() { } /// `object.mesh.addLine` at a point a mesh envelope already has (one stored without handles) adds -/// no line and records no undo step; a point between its lines adds them as one step. +/// no line and records no undo step, with or without a `color`; a point between its lines adds +/// them as one step. #[test] fn mesh_envelope_add_line_at_a_mesh_point_records_no_undo_step() { let mut s = session(); @@ -137,6 +138,9 @@ fn mesh_envelope_add_line_at_a_mesh_point_records_no_undo_step() { s.execute("object.mesh.addLine", &json!({"id": e.0, "x": x, "y": y})).unwrap(); assert_eq!((kind(&s, e), undo_len(&s)), (before.clone(), n), "({x}, {y}): nothing changed"); } + // An envelope's points carry no color, so a color there changes nothing either. + s.execute("object.mesh.addLine", &json!({"id": e.0, "x": 200, "y": 150, "color": "#00ff00"})).unwrap(); + assert_eq!((kind(&s, e), undo_len(&s)), (before.clone(), n), "a color changes nothing"); s.execute("object.mesh.addLine", &json!({"id": e.0, "x": 150, "y": 125})).unwrap(); assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 3, cols: 3, .. }), "{:?}", kind(&s, e)); assert_eq!(undo_len(&s), n + 1); diff --git a/docs/mcp.md b/docs/mcp.md index a75646d618..54d1b3f99b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9628,7 +9628,8 @@ right/left/down/up; none stored means the smooth mesh through the points, as old shows its mesh; the Mesh tool and Direct Selection drag its points and the clicked point's handles, and the Mesh tool adds a row and a column where it clicks inside. `object.mesh.addLine`, and `object.mesh.create` with `at`, take a point exactly on the mesh's outline (within a millionth of a point) on any side without `tolerance`; a point outside needs `tolerance`. `object.mesh.addLine` at a point that is already a mesh -point adds no line and records no undo step unless its `color` changes the point. `press_key` Escape during a drag of a point or handle of a mesh +point adds no line and records no undo step unless its `color` changes a gradient mesh's point (a mesh envelope's points +have no color). `press_key` Escape during a drag of a point or handle of a mesh envelope or a gradient mesh cancels it: the point goes back and no undo step is added. The Control bar shows Edit Envelope / Edit Contents, the warp's style, orientation, bend and distortions (or the mesh's rows and columns), Reset (an unbent warp, a flat mesh) and Envelope Options. From 6a14e086de5edcc36d603efc5362d337fe92fd39 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:34:42 -0800 Subject: [PATCH 20/22] Tests: the legacy .ai Color Mask test reads the notes on the options Saving with transparentImages "omit" now reports its note in the notes on the options rather than in the warnings, so the test compares every note the export gives. --- crates/engine/src/cmd/addons/image/mask/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/addons/image/mask/tests.rs b/crates/engine/src/cmd/addons/image/mask/tests.rs index 9faef84edb..b9b56a5e4a 100644 --- a/crates/engine/src/cmd/addons/image/mask/tests.rs +++ b/crates/engine/src/cmd/addons/image/mask/tests.rs @@ -765,7 +765,7 @@ fn pdf_x1a_and_x3_flatten_the_hidden_pixels() { /// image whose background was removed, and `transparentImages: "omit"` leaves both out. #[test] fn legacy_ai_flattens_a_masked_image_as_a_see_through_one() { - let warnings = |s: &Session, p: &Value| crate::cmd::fileio::encode_all(doc(s), "ai", p).unwrap().warnings; + let warnings = |s: &Session, p: &Value| crate::cmd::fileio::encode_all(doc(s), "ai", p).unwrap().notes(); let (mut masked, _) = open(&framed()); masked.execute(COLOR_MASK, &hide_white()).unwrap(); let (mut cleared, _) = open(&framed()); From 087da1c74340e5579f9db3ee0c3b1accf1632fbf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 18:35:22 -0800 Subject: [PATCH 21/22] Docs: document.open's pages sentence for a .ai without PDF compatibility appears once docs/mcp.md said twice that `pages` naming every page of a .ai saved without PDF compatibility opens it from its editing data, once with the file named and once as "such a file". The paragraph also ended in the middle of a sentence: "`clipboard.importPdf` takes every object of its editing". The sentence now appears once, with the file named, and the paragraph ends with "its editing data." Docs: docs/mcp.md. --- docs/mcp.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 207ca19d57..21c84338df 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1131,11 +1131,10 @@ page) opens from its editing data alone: its type is made from the file's text d area type and type on a path), and what can't be is left out with a warning. Non-native art (the content of a placed PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data) is drawn by the PDF importer, as a group named "Non-native art" fitted to the object's box; one whose PDF can't be read is left out with a warning. -Without editing data it says it can't be opened. `pages` that name every page of a file saved without PDF compatibility also open it from its -editing data. -Without editing data it says it can't be opened. `pages` that name every page of such a file also open it from its -editing data. `file.place` reads it from its editing data too and places the art of the layers that show on artboard -`page`, clipped to that artboard unless `crop` is `bounding`. `clipboard.importPdf` takes every object of its editing +Without editing data it says it can't be opened. `pages` that name every page of a file saved without PDF compatibility +also open it from its editing data. `file.place` reads it from its editing data too and places the art of the layers +that show on artboard `page`, clipped to that artboard unless `crop` is `bounding`. `clipboard.importPdf` takes every +object of its editing data. What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art; the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout, From 34798989bdaf0c032f8f98a34c38d9ef30d95e5e Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:45:37 -0500 Subject: [PATCH 22/22] Swatches list (#1638): M3 and area row notes --- docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 2e38263867..5d2a1dc435 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -148,7 +148,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | -| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | +| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0ad8fa1e6b..9fc4c58228 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -495,7 +495,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 |