From 1a77932167c838ebb992bbccd5f60a25ccfcd040 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:11:12 -0800 Subject: [PATCH 1/9] .ai export: the pieces of a warped image write only the pixels they show The pieces of a Warp effect, an envelope or their expanded art each held a copy of the whole image in the .ai editing data and in legacy .ai files: 200 copies for a Warp's 10 x 10 mesh. - An embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written whole the first time; later copies of the same pixels are written as the rectangle the path or mask shows, 2 pixels more on each side, with the matrix moved to keep their place. A copy that the path shows none of is written as one pixel. - An opacity mask's window covers everything the mask's art paints, its effects and symbol art included. - An image's editable original and an image in an opacity mask's art are written whole. A live Warp's drawn look is not cut to the clipping groups around the object, so a clipped image with a live Warp reads back with its Warp live. - When art that held an image's whole copy is left out (an opacity mask's art, a live envelope or a definition past the object limit), the next copy is written whole again. A whole copy written on hidden lines after an earlier one leaves the earlier one as the copy that counts. - The writers read an image's pixels once for the pieces that follow one another. Docs: docs/target-app-parity.md. --- crates/eps/src/ai_editing/tests.rs | 270 ++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 44 +++- crates/eps/src/ai_editing/writer/brushes.rs | 4 +- crates/eps/src/ai_editing/writer/images.rs | 73 +++++- crates/eps/src/ai_editing/writer/paints.rs | 4 +- crates/eps/src/ai_editing/writer/styles.rs | 4 + crates/eps/src/ai_editing/writer/symbols.rs | 4 +- crates/eps/src/ai_ops.rs | 51 +++- crates/eps/src/legacy_ai/tests.rs | 41 +++ crates/eps/src/legacy_ai/writer.rs | 61 ++++- docs/target-app-parity.md | 1 + 11 files changed, 531 insertions(+), 26 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 1321188f12..98e4194931 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7228,6 +7228,276 @@ fn an_image_on_hidden_lines_does_not_come_in_beside_its_look() { assert_eq!(art[0].name.as_deref(), Some("Photo")); } +/// A 64 × 64 RGB image, every pixel of it different, 128 pt square at (20, 10) in `doc`. +fn speckled_photo(doc: &mut Document) -> Node { + let px = image::RgbImage::from_fn(64, 64, |x, y| image::Rgb([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8])); + image_node(doc, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None) +} + +/// The sizes `%%BeginData:` gives the images' samples in the program written from `doc`. +fn sample_sizes(doc: &Document) -> Vec { + let program = export_ai_editing(doc, &opts()).unwrap().program; + String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%%BeginData: ")?.trim().parse().ok()).collect() +} + +/// The samples of a 64 × 64 RGB image, as `%%BeginData:` counts them (`XI` and CR LF too). +const SPECKLED_WHOLE: usize = 64 * 64 * 3 + 4; + +/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp or an +/// envelope on an image) are written as the image whole once, then each as the part of it that its +/// triangle shows: the file holds a little more than the image's pixels rather than a copy for every +/// piece, and it reads back drawing as it did. +#[test] +fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each of a warped image's 200 pieces was written with all of the image's samples. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 10, false, false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 200, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image"); + // Each piece holds its cell (two triangles share one) and 2 pixels more around it. + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The pieces of a warped image that Preserve Shape Using Transparency cuts with opacity masks that +/// clip are written as the parts their masks show, and the file reads back drawing as it did. +#[test] +fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each masked piece of a warped image was written with all of the image's samples. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 32, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image"); + let total: usize = sizes.iter().sum(); + assert!(total < 6 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// An image with a live Warp keeps its pixels whole once, and its drawn look's pieces are each the +/// part of them they show: it reads back as the image with its Warp live. +#[test] +fn an_image_with_a_live_warp_writes_its_pixels_whole_once() { + // GUARDS: a live Warp's drawn look wrote all of the image's samples for each of its pieces. + let doc = photo_doc(|d| { + let mut photo = speckled_photo(d); + photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + photo + }); + let sizes = sample_sizes(&doc); + assert!(sizes.len() > 100, "{sizes:?}"); + assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "{sizes:?}"); + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + assert_eq!(art.len(), 1, "{:#?}", lines(&back)); + assert!(matches!(art[0].kind, NodeKind::Image(_)), "{:?}", art[0].kind); + assert_eq!(art[0].appearance.effects.len(), 1, "{:?}", art[0].appearance); +} + +/// An image cut to a clipping path is written with all of its pixels when it is the first of them +/// written, so releasing the clipping path shows the whole image; a second copy cut to a small part +/// is written as that part, 2 pixels more on each side. +#[test] +fn a_clipped_image_written_first_keeps_all_of_its_pixels() { + let window = |d: &mut Document| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + group(d, vec![corner, photo], true) + }; + let doc = photo_doc(|d| { + let (a, b) = (window(d), window(d)); + group(d, vec![a, b], false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 2, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE); + // The path shows 10 × 10 pixels. + assert_eq!(sizes[1], 14 * 14 * 3 + 4, "{sizes:?}"); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.002 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + +/// The images in `n` and its members, each as its count of effects, its width and its height. +fn images_in(n: &Node, out: &mut Vec<(usize, u32, u32)>) { + if let NodeKind::Image(im) = &n.kind { + out.push((n.appearance.effects.len(), im.width, im.height)); + } + for c in n.children().into_iter().flatten() { + images_in(c, out); + } +} + +/// A copy of an image written whole already that its clipping path shows none of is written as one +/// pixel of it. +#[test] +fn a_copy_of_an_image_that_does_not_show_is_one_pixel() { + // GUARDS: a copy whose clipping path missed the image was written with all of its pixels. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + let first = group(d, vec![corner, photo], true); + let photo = speckled_photo(d); + let away = clipping(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 5.0))); + let second = group(d, vec![away, photo], true); + group(d, vec![first, second], false) + }); + assert_eq!(sample_sizes(&doc), [SPECKLED_WHOLE, 3 + 4]); +} + +/// A 200 × 100 document of: the speckled image when `first`; a square whose opacity mask's art is +/// the image then six squares; the image in a small clipping group. +fn mask_art_then_clipped_photo(first: bool) -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + if first { + let photo = speckled_photo(&mut doc); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + } + let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5)); + // The image, then more objects than the clipping group after the mask holds. + let mut members = vec![speckled_photo(&mut doc)]; + for i in 0..6 { + let x = f64::from(i) * 10.0; + members.push(square(&mut doc, Rect::new(x, 0.0, x + 5.0, 5.0), Color::rgb(1.0, 1.0, 1.0))); + } + let art = group(&mut doc, members, false); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + doc.insert(Some(layer), usize::MAX, masked).unwrap(); + let photo = speckled_photo(&mut doc); + let corner = clipping(&mut doc, shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0))); + let clipped = group(&mut doc, vec![corner, photo], true); + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + doc +} + +/// For each object limit from none to all of `doc`'s objects: whether the mask is written, and the +/// sizes `%%BeginData:` gives the images' samples on `%_` lines (the mask's art) and on the others. +fn samples_within_each_limit(doc: &Document) -> Vec<(usize, bool, Vec, Vec)> { + let read = crate::import::objects_read(&export_ai_editing(doc, &opts()).unwrap().program).unwrap(); + (0..=read) + .map(|max| { + let text = String::from_utf8_lossy(&export_within(doc, &opts(), max).unwrap().program).replace("\r\n", "\n"); + let size = |l: &str| l.strip_prefix("%%BeginData: ")?.trim().parse::().ok(); + let hidden = text.lines().filter_map(|l| size(l.strip_prefix("%_")?)).collect(); + let shown = text.lines().filter_map(size).collect(); + (max, text.contains("(AI9_Mask)"), hidden, shown) + }) + .collect() +} + +/// An image whose whole copy was in art left out for the object limit (here an opacity mask's art) +/// is written whole again where it is next written, even cut to a small clipping path: the file +/// always holds all of its pixels once. +#[test] +fn an_image_whose_whole_copy_is_left_out_is_written_whole_again() { + // GUARDS: once the mask's art was left out, the clipped copy was written as the part it shows. + let mut left_out_with_copy = 0; + for (max, mask, hidden, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(false)) { + if hidden.is_empty() && shown.is_empty() { + continue; + } + assert!(hidden.contains(&SPECKLED_WHOLE) || shown.contains(&SPECKLED_WHOLE), "room for {max}: {hidden:?} {shown:?}"); + left_out_with_copy += usize::from(!mask); + } + assert!(left_out_with_copy > 0, "no room left the mask out and kept the clipped copy"); +} + +/// An image written whole, then whole again in an opacity mask's art left out for the object limit, +/// is not written whole a third time: the first copy is kept, and the clipped copy after the mask is +/// the part it shows. +#[test] +fn an_image_written_whole_before_art_left_out_stays_written_whole() { + // GUARDS: the mask's copy took the first copy's place, so giving it back wrote the clipped copy whole. + let mut left_out = 0; + for (max, mask, _, shown) in samples_within_each_limit(&mask_art_then_clipped_photo(true)) { + assert!(shown.iter().filter(|s| **s == SPECKLED_WHOLE).count() <= 1, "room for {max}: {shown:?}"); + left_out += usize::from(!mask && shown.len() == 2); + } + assert!(left_out > 0, "no room left the mask out and kept both copies"); +} + +/// An image with a live Warp in a small clipping group keeps all of its pixels: its drawn look is +/// written first, and neither the look nor the image itself is cut to the clipping path, so it +/// reads back with its Warp live and every pixel it had. +#[test] +fn a_clipped_image_with_a_live_warp_keeps_all_of_its_pixels() { + // GUARDS: the image under a live Warp was cut to the clipping path around it. + let doc = photo_doc(|d| { + let mut photo = speckled_photo(d); + photo.appearance = Appearance { effects: vec![fx("warp.arc", serde_json::json!({"bend": 40.0}))], ..Appearance::default() }.into(); + let corner = clipping(d, shapes::rectangle(Rect::new(30.0, 20.0, 60.0, 50.0))); + group(d, vec![corner, photo], true) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(), 1, "the look's first piece: {sizes:?}"); + let total: usize = sizes.iter().sum(); + assert!(total < 10 * SPECKLED_WHOLE, "{total} bytes of samples for an image of {SPECKLED_WHOLE}"); + // The image itself, on `%_` lines. + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let hidden: Vec = + String::from_utf8_lossy(&program).lines().filter_map(|l| l.strip_prefix("%_%%BeginData: ")?.trim().parse().ok()).collect(); + assert_eq!(hidden, [SPECKLED_WHOLE]); + let back = read_back(&doc); + let mut images = vec![]; + for n in back.layers[0].children().unwrap() { + images_in(n, &mut images); + } + assert_eq!(images, [(1, 64, 64)], "{:#?}", lines(&back)); +} + +/// An image cut by an opacity mask whose art has an effect that paints past its path keeps the +/// pixels the effect shows: a copy of an image written whole already is cut to all that its mask +/// paints. +#[test] +fn a_masked_image_keeps_the_pixels_its_mask_effects_show() { + // GUARDS: the copy was cut to the mask art's path, short of what its Offset Path paints. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let corner = clipping(d, shapes::rectangle(Rect::new(20.0, 10.0, 24.0, 14.0))); + let first = group(d, vec![corner, photo], true); + let mut masked = speckled_photo(d); + let mut ap = Appearance::basic(solid(Color::WHITE), Paint::None, 0.0); + ap.effects = vec![fx("path.offsetPath", serde_json::json!({"offset": 30.0}))]; + let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 40.0, 80.0, 50.0)), ap); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true })); + group(d, vec![first, masked], false) + }); + let sizes = sample_sizes(&doc); + assert_eq!(sizes.len(), 2, "{sizes:?}"); + assert_eq!(sizes[0], SPECKLED_WHOLE); + // The offset art covers (40, 10) to (110, 80): 35 × 35 pixels of the image. + assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}"); +} + /// A linked image, an image with an opacity mask, and an image with a Warp and another effect are /// written as their drawn art, with no art style. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 7db5d11896..689e7ca87f 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -270,6 +270,15 @@ pub(super) struct Writer<'a> { /// How many more envelopes may be written live: as many as the reader checks in one file /// ([`crate::import::ENVELOPE_CHECKS`]). envelopes_left: usize, + /// The image read last and its pixels ([`images::Samples::of`], `None` when they can't be + /// read): the pieces of a warped image, which follow one another, read them once. + last_samples: Option<(vectorcraft_doc::ImageBlob, Option)>, + /// The images written whole so far, by the address of their data (which each entry keeps), each + /// with the index of its samples in [`Self::payloads`]. + whole_images: HashMap>, usize)>, + /// The outlines of the clipping groups the art being written is in, in the document (empty in + /// a definition, an opacity mask's art and a brush's art, which are written apart from them). + shown: Vec, } impl<'a> Writer<'a> { @@ -334,6 +343,9 @@ impl<'a> Writer<'a> { definition_objects: 0, definition_symbol_nodes: 0, max_objects: crate::import::MAX_EDITING_OBJECTS, + last_samples: None, + whole_images: HashMap::new(), + shown: vec![], } } @@ -400,15 +412,21 @@ impl<'a> Writer<'a> { /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are /// given back, except those of the definitions written since, and the images `lines` marks - /// are emptied (the others keep their indices). + /// are emptied (the others keep their indices). An image whose whole copy is among them is no + /// longer written whole ([`Self::whole_images`]). fn give_back(&mut self, c: Charges, lines: &str) { self.objects = c.objects + (self.definition_objects - c.kept_objects); self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + let mut emptied = HashSet::new(); for index in lines.split(PAYLOAD).skip(1).step_by(2) { - if let Some(p) = index.parse::().ok().and_then(|i| self.payloads.get_mut(i)) { + if let Some((i, p)) = index.parse::().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) { *p = Vec::new(); + emptied.insert(i); } } + if !emptied.is_empty() { + self.whole_images.retain(|_, (_, i)| !emptied.contains(i)); + } } /// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written @@ -654,7 +672,23 @@ 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(&self.doc.shown_image(n, im), xy), + NodeKind::Image(im) => { + // An opacity mask that clips shows the image within what its art paints only (its + // effects and its symbol instances' art included, as the renderer measures it). + let masked = n + .extras() + .mask + .as_ref() + .filter(|m| m.clip && !m.invert && !m.disabled) + .and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art)); + if let Some(b) = masked { + self.shown.push(b.to_path(0.1)); + } + self.image(&self.doc.shown_image(n, im), xy); + if masked.is_some() { + self.shown.pop(); + } + } 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") { @@ -828,6 +862,7 @@ impl<'a> Writer<'a> { fn mask(&mut self, m: &OpacityMask) -> bool { let outer = std::mem::take(&mut self.body); let (state, gs, clip) = (self.state, self.gs, self.clip.take()); + let shown_by = std::mem::take(&mut self.shown); let (charges, bound) = (self.charges(), self.bound.len()); (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); // The canvas draws the art's root even when it is hidden, and the reader puts a hidden object @@ -861,6 +896,7 @@ impl<'a> Writer<'a> { self.hidden -= 1; self.nest -= 1; let lines = std::mem::replace(&mut self.body, outer); + self.shown = shown_by; if self.left_out_since(charges) { // The art isn't written, nor are the variable bindings of its objects. self.give_back(charges, &lines); @@ -946,9 +982,11 @@ impl<'a> Writer<'a> { } } self.clip_paint_part(first, &path, rule, &fills); + self.shown.push(path.to_bezpath()); for c in rest { self.node(c); } + self.shown.pop(); self.clip_paint_part(first, &path, rule, &strokes); self.state(first.locked, !first.visible); let start = self.body.len(); diff --git a/crates/eps/src/ai_editing/writer/brushes.rs b/crates/eps/src/ai_editing/writer/brushes.rs index 2c281e702d..36730c73a6 100644 --- a/crates/eps/src/ai_editing/writer/brushes.rs +++ b/crates/eps/src/ai_editing/writer/brushes.rs @@ -373,7 +373,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state.take(), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), (self.block_start, self.block_end), // The art is the brush's own: not on `%_` lines, in a drawn look or in a symbol that // first uses the brush. @@ -410,7 +410,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state, self.gs, - self.clip, + (self.clip, self.shown), (self.block_start, self.block_end), (self.hidden, self.in_look, self.in_symbol), ) = saved; diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 36c21c14eb..d69b1d7e09 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -2,13 +2,14 @@ //! (see [`crate::ai_editing`] for the form). use std::fmt::Write; +use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{FillLayer, ImageBlob, ImageObject, LinkInfo}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape}; use super::{Writer, Xy}; -use crate::ps; +use crate::{ai_ops, ps}; pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps"; pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps"; @@ -26,8 +27,11 @@ pub(in crate::ai_editing) const PAYLOAD: char = '\u{1}'; /// What starts the line of an image's samples: `XI` and its payload, then `%%EndData`. pub(super) const SAMPLES_LINE: &str = "XI\u{1}"; +/// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]). +pub(super) type Read = Arc<(Samples, bool)>; + /// An image's pixels as the data holds them. -struct Samples { +pub(super) struct Samples { /// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`. space: &'static str, /// The image type of `XI` (§8.1): 1 gray, 3 RGB, 4 CMYK. @@ -79,6 +83,19 @@ impl Samples { Some((Samples { space, kind, width, height, color, alpha }, deep)) } + /// The pixels of `window` (`[x0, y0, x1, y1]`, within the image) alone. + fn window(&self, window: [u32; 4]) -> Samples { + let [x0, y0, x1, y1] = window; + Samples { + space: self.space, + kind: self.kind, + width: x1 - x0, + height: y1 - y0, + color: ai_ops::window_samples(&self.color, self.width, usize::from(self.kind), window), + alpha: self.alpha.as_ref().map(|a| ai_ops::window_samples(a, self.width, 1, window)), + } + } + /// `XI`'s fourth flag as the files users sent have it: 0 gray, 1 RGB, 2 CMYK, 16 more with an /// alpha channel. fn reserved(&self) -> u8 { @@ -95,10 +112,13 @@ impl Writer<'_> { /// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is - /// cut to a clipping path, the image is in a clipping group of that path. + /// cut to a clipping path, the image is in a clipping group of that path; a raster of pixels + /// written whole already is then the part of them that the path shows ([`ai_ops::image_window`]). pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; - let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else { + let blob = doc.image_blob(im); + let read = blob.and_then(|b| self.samples(b)); + let Some((samples, deep)) = read.as_deref() else { let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1))); self.warn(IMAGES); return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy); @@ -112,7 +132,7 @@ impl Writer<'_> { if !self.take_objects(1 + clipping) { return; } - if deep { + if *deep { self.warn(IMAGE_DEPTH); } let cut = self.clip.take(); @@ -128,13 +148,38 @@ impl Writer<'_> { Some(link) if self.hidden == 0 && !self.in_symbol => { // Cut to a region, the placed file is in a clipping group. self.placed = cut.is_none(); - self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf); + self.placed_file(link, samples, (im.width.max(1), im.height.max(1)), im.xf); } other => { if other.is_some() { self.warn(LINKS_EMBEDDED); } - self.raster(&samples, im.xf * fit); + // An image written whole already: the part its region shows, placed where it is in + // the image (the pieces of a warped image each show a small part of the same one). + // An object's editable original (on `%_` lines) and an image in an opacity mask's art + // are written whole. + let data = blob.map(|b| &b.bytes); + let written = self.hidden == 0 && data.is_some_and(|d| self.whole_images.contains_key(&(Arc::as_ptr(d) as usize))); + let window = written + .then(|| { + let clips: Vec<_> = self.shown.iter().cloned().chain(cut.iter().map(PathData::to_bezpath)).collect(); + ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height)) + }) + .flatten(); + match window { + Some(window) => { + self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1])))) + } + None => { + if let Some(d) = data { + // `raster` adds the samples as the next payload. A copy written whole on + // `%_` lines after an earlier whole copy keeps the earlier one's entry. + let next = self.payloads.len(); + self.whole_images.entry(Arc::as_ptr(d) as usize).or_insert_with(|| (d.clone(), next)); + } + self.raster(samples, im.xf * fit); + } + } } } if let Some(region) = &cut { @@ -231,6 +276,20 @@ impl Writer<'_> { ); } + /// The pixels of `blob` ([`Samples::of`]), read once for images that follow one another with + /// the same data. + fn samples(&mut self, blob: &ImageBlob) -> Option { + if let Some((last, read)) = &self.last_samples + && Arc::ptr_eq(&last.bytes, &blob.bytes) + && last.mime == blob.mime + { + return read.clone(); + } + let read = Samples::of(blob).map(Arc::new); + self.last_samples = Some((blob.clone(), read.clone())); + read + } + /// Keep `bytes` to go in the program where the mark this returns is. fn payload(&mut self, bytes: Vec) -> String { self.payloads.push(bytes); diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index f1b52dd91b..d0a90ce86a 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -481,7 +481,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas), self.state.take(), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), self.block_end, ); // `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its @@ -505,7 +505,7 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; let art; - (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved; + (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); if self.left_out_since(before) { diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index fe3e68ce24..ef5072e79d 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -742,6 +742,9 @@ impl Writer<'_> { let xy = Xy::of(orig); let was = self.in_look; self.in_look = true; + // The reader draws the look apart from the clipping groups around the object to compare it + // with the object's effects, and keeps the object itself: neither is cut to those groups. + let shown_by = std::mem::take(&mut self.shown); self.line("u"); let mut look = n.clone(); (look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = @@ -785,6 +788,7 @@ impl Writer<'_> { } self.dictionary(orig); self.hidden -= 1; + self.shown = shown_by; let part = std::mem::replace(&mut self.body, saved); hide(&part, &mut self.body); self.block_end = usize::MAX; diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 9b9724f843..84033a959f 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -243,7 +243,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas, self.instance_space), self.state.replace((false, false)), self.gs.replace(Xy::OPAQUE), - self.clip.take(), + (self.clip.take(), std::mem::take(&mut self.shown)), (self.block_start, self.block_end), (std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)), self.root_entries.take(), @@ -300,7 +300,7 @@ impl Writer<'_> { (self.to_art, self.grad_space, self.canvas, self.instance_space), self.state, self.gs, - self.clip, + (self.clip, self.shown), (self.block_start, self.block_end), (self.in_symbol, self.hidden), self.root_entries, diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 3b2bdd88d8..6b1374cd83 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -7,7 +7,7 @@ use std::fmt::Write; use vectorcraft_color::{Color, Gradient}; use vectorcraft_doc::{LAYER_COLORS, LayerColor, LineCap, LineJoin, StrokeLayer}; -use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Shape, SubPath}; use crate::ps; @@ -131,6 +131,55 @@ pub(crate) fn average(g: &Gradient) -> Color { Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) } +/// The pixels an image's part keeps past the edge of the region that shows it, on each side: as +/// many as smoothing reads from beside a pixel, and one more. +const WINDOW_MARGIN: f64 = 2.0; + +/// The part of an image of `w` × `h` pixels, which `to_doc` places in the document, that regions +/// `clips` (each in the document, the image cut to all of them) show, with [`WINDOW_MARGIN`] pixels +/// more on each side: `[x0, y0, x1, y1]`, in pixels from the top left. One pixel of the image, the +/// nearest to where the regions are, when they show none of it. `None` when that part has half the +/// image's pixels or more, or when `to_doc` can't be undone; the whole image is written then. +pub(crate) fn image_window(clips: &[kurbo::BezPath], to_doc: Affine, (w, h): (u32, u32)) -> Option<[u32; 4]> { + if clips.is_empty() || w == 0 || h == 0 || !to_doc.is_finite() || to_doc.determinant().abs() < 1e-12 { + return None; + } + let to_pixels = to_doc.inverse(); + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, f64::from(w), f64::from(h)); + for clip in clips { + let b = (to_pixels * clip.clone()).bounding_box(); + if !b.is_finite() { + return None; + } + x0 = f64::max(x0, b.x0.floor() - WINDOW_MARGIN); + y0 = f64::max(y0, b.y0.floor() - WINDOW_MARGIN); + x1 = f64::min(x1, b.x1.ceil() + WINDOW_MARGIN); + y1 = f64::min(y1, b.y1.ceil() + WINDOW_MARGIN); + } + if x0 >= x1 || y0 >= y1 { + let (x, y) = ((x0 as u32).min(w - 1), (y0 as u32).min(h - 1)); + return Some([x, y, x + 1, y + 1]); + } + if 2.0 * (x1 - x0) * (y1 - y0) >= f64::from(w) * f64::from(h) { + return None; + } + Some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) +} + +/// The rows `y0..y1` and columns `x0..x1` (`window`) of `samples`, `w` pixels a row of `channels` +/// bytes each, rows from the top. +pub(crate) fn window_samples(samples: &[u8], w: u32, channels: usize, [x0, y0, x1, y1]: [u32; 4]) -> Vec { + let row = w as usize * channels; + let (from, to) = (x0 as usize * channels, x1 as usize * channels); + let mut out = Vec::with_capacity((y1 - y0) as usize * (to - from)); + for y in y0 as usize..y1 as usize { + if let Some(part) = samples.get(y * row + from..y * row + to) { + out.extend_from_slice(part); + } + } + out +} + /// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) { let cap = match st.cap { diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 6b2f8335b8..bb68c520d2 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -658,6 +658,47 @@ fn images_are_written_in_the_form_the_specification_shows() { assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}"); } +/// The pieces of an image cut into a raster mesh warp (as Object › Expand leaves a Warp on an +/// image) are written as the image whole once, then each as the part of it that its triangle shows, +/// and the file draws as the document does ([`write`] runs it). +#[test] +fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { + // GUARDS: each of a warped image's 200 pieces was written with all of the image's samples. + let mut doc = Document::new(200.0, 160.0); + let mut png = vec![]; + image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { + key, + width: 64, + height: 64, + xf: Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), + link: None, + placement: Default::default(), + pixels: None, + }; + let photo = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im.clone()))); + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + let warped = vectorcraft_doc::live::mesh_warp_image(&photo, &im, &bend, 10, false, false); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + let text = String::from_utf8(write(&doc).bytes).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 200); + let digits = |lines: &Vec<&str>| { + lines.iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).map(str::len).sum::() + }; + let whole = 64 * 64 * 3 * 2; + assert_eq!(digits(&images[0]), whole, "the first piece holds the whole image"); + // Each piece holds its cell (two triangles share one) and 2 pixels more around it. + let total: usize = images.iter().map(digits).sum(); + assert!(total < 10 * whole, "{total} digits of samples for an image of {whole}"); +} + /// An image whose data is empty is left out with the note for empty or too large images, and an /// image whose data can't be decoded is left out with the decode note. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 20f8b32b96..9ab947bdc8 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -45,6 +45,13 @@ pub(super) struct Writer<'a> { roman: bool, /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type). kumi: bool, + /// The outlines of the clipping groups the art being written is in, in the document. + clips: Vec, + /// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow + /// one another, decode it once. + last_image: Option<(ImageBlob, Option>)>, + /// The images written whole so far, by the address of their data (which each entry keeps). + whole_images: std::collections::HashMap>>, } impl<'a> Writer<'a> { @@ -68,6 +75,9 @@ impl<'a> Writer<'a> { fonts: vec![], roman: false, kumi: false, + clips: vec![], + last_image: None, + whole_images: std::collections::HashMap::new(), } } @@ -360,12 +370,14 @@ impl<'a> Writer<'a> { if several { self.line("*U"); } + self.clips.push(outline); if let Some(fill) = &paint.fill { self.node(fill, locked); } for c in rest { self.node(c, locked); } + self.clips.pop(); self.lock(locked); self.line("Q"); if let Some(stroke) = &paint.stroke { @@ -992,7 +1004,8 @@ impl<'a> Writer<'a> { /// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its /// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color, /// and a 1 bit for each white pixel, which is clear. Another colorized image is written with - /// its color in its pixels. + /// its color in its pixels. In a clipping group, an image whose pixels were written whole + /// already is written as the part of them the group shows ([`ai_ops::image_window`]). fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) { let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let m = self.to_art * xf * flip; @@ -1003,7 +1016,7 @@ impl<'a> Writer<'a> { self.warn(LARGE_IMAGE); return; } - let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else { + let Some((blob, img)) = blob.and_then(|b| Some((b, self.decoded(b)?))) else { self.warn(UNDECODED); return; }; @@ -1014,14 +1027,31 @@ impl<'a> Writer<'a> { } // The object places the image's own pixel grid (its width × height) wherever the file's is. let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); - let m = self.to_art * xf * fit * flip; + let coverage = match colorize { + Some(paint) => { + let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { + self.warn(UNDECODED); + return; + }; + Some((paint, coverage)) + } + None => None, + }; + // An image written whole already, cut to a clipping group: the part the group shows, placed + // where it is in the image (the pieces of a warped image each show a small part of the same one). + let key = Arc::as_ptr(&blob.bytes) as usize; + let window = if self.whole_images.contains_key(&key) { ai_ops::image_window(&self.clips, xf * fit, (w, h)) } else { None }; + if window.is_none() { + self.whole_images.insert(key, blob.bytes.clone()); + } + let window = window.unwrap_or([0, 0, w, h]); + let [x0, y0, x1, y1] = window; + let m = self.to_art * xf * fit * Affine::translate((f64::from(x0), f64::from(y0))) * flip; + let (w, h) = (x1 - x0, y1 - y0); // Over white. let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8; - if let Some(paint) = colorize { - let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { - self.warn(UNDECODED); - return; - }; + if let Some((paint, coverage)) = coverage { + let coverage = ai_ops::window_samples(&coverage, img.width(), 1, window); if coverage.iter().all(|&v| v == 0 || v == 255) { let stride = (w as usize).div_ceil(8); let mut bits = vec![0xFF; stride.saturating_mul(h as usize)]; @@ -1041,7 +1071,7 @@ impl<'a> Writer<'a> { self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); return; } - let rgba = img.to_rgba8(); + let rgba = img.crop_imm(x0, y0, w, h).to_rgba8(); let mut samples = Vec::with_capacity(rgba.len() / 4 * 3); let mut see_through = false; for px in rgba.pixels() { @@ -1057,6 +1087,19 @@ impl<'a> Writer<'a> { self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples); } + /// The pixels of `blob`, decoded once for images that follow one another with the same data. + fn decoded(&mut self, blob: &ImageBlob) -> Option> { + if let Some((last, img)) = &self.last_image + && Arc::ptr_eq(&last.bytes, &blob.bytes) + && last.mime == blob.mime + { + return img.clone(); + } + let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new); + self.last_image = Some((blob.clone(), img.clone())); + img + } + /// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]` /// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`] /// digits, `%AI5_EndRaster`, then `F`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..dbf0890bba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -616,6 +616,7 @@ since 2026-10-01. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. - **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. +- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now add about twice the image's pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. From 8703859c66f15db85542b5c33b02784c10890176 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:10:11 -0800 Subject: [PATCH 2/9] Contextual Task Bar: Smooth opens the Smooth slider with Direct Selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a path selected whole by the Direct Selection tool, the bar's "Smooth" button converted every anchor point to smooth at once. It was the anchor convert, labeled like the path Smooth that the overview lists beside Simplify as adjusting the path's curvature, and the bar had no button for Object › Path › Smooth…. - A path selected whole with Direct Selection now offers Smooth after Simplify. It runs object.path.smooth the way the menu item does, so it opens the Smooth slider and changes nothing until it is confirmed. - The two anchor convert buttons, with a whole path or with anchors selected, show their icons alone, named by the Control bar's tool tips "Convert Selected Anchor Points to Corner" and "to Smooth". The keyboard shortcut part of #6784 is fixed on main by #6810. Docs: docs/mcp.md, docs/target-app-parity.md, the ui.smoothAuto params. Fixes #6784 --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/canvas/task_bar.rs | 40 ++++++--- crates/ui-egui/src/canvas/tests.rs | 48 +++++++++-- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/tests/taskbar.rs | 107 ++++++++++++++++++++---- docs/mcp.md | 7 +- docs/target-app-parity.md | 2 +- 7 files changed, 171 insertions(+), 37 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 8836b72ac2..9557bf6bf8 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1258,7 +1258,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index e908640c25..f89a921371 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -163,8 +163,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none())); items.push((tl!("Connect"), "dc-join", "path.join", none())); items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none())); - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); + items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); } // As in the reference app, open paths offer Join and Edit Path, compound paths and clipping // masks their Release, images selected with vector art Make Clipping Mask, type converts @@ -213,14 +213,17 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } else if is_image { // Offset Path and Simplify don't apply to an image. } else if !anchors && direct { - // Corner and Smooth convert every anchor of a path selected whole; Remove and Cut - // need anchors picked out, so they wait for them. + // The two convert buttons convert every anchor of a path selected whole; Remove and Cut + // need anchors picked out, so they wait for them. Simplify and Smooth open their + // sliders, as Object › Path › Simplify… and Smooth… do; the page above names Smooth as + // adjusting the path's curvature, apart from converting its anchor points. if open_path { items.push((tl!("Connect"), "dc-join", "path.join", none())); } - items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); - items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); + items.push((tl!("Convert Selected Anchor Points to Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none())); + items.push((tl!("Convert Selected Anchor Points to Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); items.push((tl!("Simplify"), "spline", "object.path.simplify", none())); + items.push((tl!("Smooth"), "dc-smooth", "object.path.smooth", none())); } else if !anchors { if open_path { items.push((tl!("Join"), "dc-join", "path.join", none())); @@ -297,6 +300,14 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } for (label, icon, cmd, params) in &items { + // The convert buttons show their icon alone, named by their hover text, as + // the Control bar's do. + if icon_only(cmd) { + if widgets::icon_button(ui, icon, label, false, 30.0).clicked() { + run = Some((*cmd, params.clone())); + } + continue; + } let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); @@ -405,7 +416,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})), Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app), Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app), - // Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do. + // The convert buttons convert the direct-selected anchors, as the Control bar's buttons do. Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})), Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})), Some((c, p)) => crate::menus::invoke(app, c, p), @@ -419,10 +430,16 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { /// `room` (and narrow to fit it there). fn size_estimate(items: &[(&str, &str, &str, serde_json::Value)], fonts: bool, fixed: f32, room: f32) -> (bool, Vec2) { let font_w: f32 = crate::panels::character::TASK_BAR_WIDTHS.iter().filter(|_| fonts).map(|w| w + 6.0).sum(); - let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let width = |(l, _, cmd, _): &(&str, &str, &str, serde_json::Value)| if icon_only(cmd) { 36.0 } else { l.len() as f32 * 7.2 + 44.0 }; + let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(width).sum::(); if fonts && items_w + font_w > room { (true, vec2(items_w.max((font_w + 16.0).min(room)), 80.0)) } else { (false, vec2(items_w + font_w, 44.0)) } } +/// Whether the bar's item that runs `cmd` shows its icon alone: the anchor convert buttons. +fn icon_only(cmd: &str) -> bool { + matches!(cmd, TASK_BAR_TO_CORNER | TASK_BAR_TO_SMOOTH) +} + /// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or /// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints. pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) { @@ -473,8 +490,11 @@ const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards"; /// The Contextual Task Bar's Rearrange All button with the Artboard tool. const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards"; -/// The Contextual Task Bar's Corner button for direct-selected anchors. +/// The Contextual Task Bar's Convert Selected Anchor Points to Corner button, for direct-selected +/// anchors or a path selected whole with Direct Selection. const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner"; -/// The Contextual Task Bar's Smooth button for direct-selected anchors. +/// The Contextual Task Bar's Convert Selected Anchor Points to Smooth button, for direct-selected +/// anchors or a path selected whole with Direct Selection. The bar's Smooth button runs +/// `object.path.smooth` instead. const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth"; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d6df378c87..dc95453b98 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10350,9 +10350,34 @@ fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() { assert_eq!(boards(&app), before, "one undo step"); } +/// The names and places of the buttons the Contextual Task Bar draws, from the accessibility tree +/// (`ctx` has it enabled): an icon-only button is named by its hover text. +fn task_bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { + frame(app, ctx, vec![]); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let Some(bar) = task_bar_rect(ctx) else { return vec![] }; + let Some(update) = out.platform_output.accesskit_update.as_ref() else { return vec![] }; + let mut v: Vec<(String, egui::Rect)> = update + .nodes + .iter() + .filter(|(_, n)| n.role() == egui::accesskit::Role::Button) + .filter_map(|(_, n)| { + let b = n.bounds()?; + let r = egui::Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32)); + let label = n.label()?; + bar.contains(r.center()).then(|| (label.to_string(), r)) + }) + .collect(); + v.sort_by(|a, b| a.1.left().total_cmp(&b.1.left())); + v +} + /// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect, -/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens -/// the path there and Connect closes it again, as the Control bar's anchor buttons do. +/// Cut Path and the two convert buttons, which show their icons alone. Converting to smooth and to +/// corner changes the selected anchor, Cut Path opens the path there and Connect closes it again, +/// as the Control bar's anchor buttons do. #[test] fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -10361,10 +10386,21 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { app.select_tool("directSelection"); app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); let ctx = egui::Context::default(); + ctx.enable_accesskit(); let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::>(); - assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]); + assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Duplicate"]); + let buttons = task_bar_buttons(&mut app, &ctx).into_iter().map(|(t, _)| t).collect::>(); + let expected = [ + "Remove Anchor Points", + "Connect", + "Cut Path", + "Convert Selected Anchor Points to Corner", + "Convert Selected Anchor Points to Smooth", + "Duplicate", + ]; + assert_eq!(buttons[..expected.len()], expected, "{buttons:?}"); let press = |app: &mut VectorcraftApp, label: &str| { - let labels = task_bar_labels(app, &ctx); + let labels = task_bar_buttons(app, &ctx); let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center(); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); @@ -10374,11 +10410,11 @@ fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() { let subpath = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone(); let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone(); - press(&mut app, "Smooth"); + press(&mut app, "Convert Selected Anchor Points to Smooth"); let a = subpath(&app).anchors[1]; assert!(a.has_in() && a.has_out(), "smooth: {a:?}"); assert_eq!(last_undo(&app), "Convert Anchor Points"); - press(&mut app, "Corner"); + press(&mut app, "Convert Selected Anchor Points to Corner"); let a = subpath(&app).anchors[1]; assert!(!a.has_in() && !a.has_out(), "corner: {a:?}"); press(&mut app, "Cut Path"); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 37cdcb2d56..b21c28006c 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1259,7 +1259,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.smoothAuto", "Auto-Smooth", "", - "{} the open Smooth slider (Object › Path › Smooth and the canvas context menu's Smooth… open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", + "{} the open Smooth slider (Object › Path › Smooth, the canvas context menu's Smooth… and the Contextual Task Bar's Smooth open it: dialog `smoothSlider` with field `level` 0..100, object.path.smooth's level, opening at the level object.path.smooth {auto: true} picks, previewed on the canvas until ui.dialog.confirm keeps it as one undo step) moves to the level object.path.smooth {auto: true} picks and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 68ef8a3f15..1d29663473 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -54,6 +54,42 @@ fn click(app: &mut VectorcraftApp, label: &str) { frame(app, &ctx, vec![]); } +/// The names and places of the buttons the task bar draws, from the accessibility tree: an +/// icon-only button is named by its hover text. +fn buttons(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + frame(app, &ctx, vec![]); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let named = nodes + .iter() + .filter(|(_, n)| n.role() == egui::accesskit::Role::Button) + .filter_map(|(_, n)| { + let b = n.bounds()?; + Some((n.label()?.to_string(), Rect::from_min_max(Pos2::new(b.x0 as f32, b.y0 as f32), Pos2::new(b.x1 as f32, b.y1 as f32)))) + }) + .collect(); + (ctx, named) +} + +/// Click the task bar's button named `name` (its label, or an icon-only button's hover text). +fn click_button(app: &mut VectorcraftApp, name: &str) { + let (ctx, named) = buttons(app); + let p = named.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no button named {name:?} in {named:?}")).1.center(); + let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![Event::PointerMoved(p)]); + frame(app, &ctx, vec![press(true)]); + frame(app, &ctx, vec![press(false)]); + frame(app, &ctx, vec![]); +} + +const TO_CORNER: &str = "Convert Selected Anchor Points to Corner"; +const TO_SMOOTH: &str = "Convert Selected Anchor Points to Smooth"; + fn select(app: &mut VectorcraftApp, ids: &[u64]) { app.run("select.set", json!({"ids": ids})).unwrap(); } @@ -293,27 +329,67 @@ fn direct_selection_of_a_whole_path_offers_its_anchor_actions() { click(&mut app, "Edit Path"); assert_eq!(app.session.tool_id(), "directSelection"); assert!(app.session.active().unwrap().selection.anchors.is_empty(), "the path is selected whole"); + let (_, named) = buttons(&mut app); + let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect(); + for name in ["Connect", TO_CORNER, TO_SMOOTH, "Simplify", "Smooth", "Duplicate"] { + assert!(names.contains(&name), "{name}: {names:?}"); + } + for name in ["Edit Path", "Offset Path", "Join", "Remove Anchor Points", "Cut Path"] { + assert!(!names.contains(&name), "{name}: {names:?}"); + } + // The convert buttons show their icons alone: the bar draws no convert text, and one "Smooth". let (_, texts) = bar(&mut app); - for label in ["Connect", "Corner", "Smooth", "Simplify", "Duplicate"] { - assert!(has(&texts, label), "{label}: {texts:?}"); - } - for label in ["Edit Path", "Offset Path", "Join", "Remove Anchor Points", "Cut Path"] { - assert!(!has(&texts, label), "{label}: {texts:?}"); - } - // Smooth and Corner convert every anchor of the path, one undo step each. - click(&mut app, "Smooth"); + assert!(!has(&texts, "Corner") && !texts.iter().any(|(t, _)| t.starts_with("Convert")), "{texts:?}"); + assert_eq!(texts.iter().filter(|(t, _)| t == "Smooth").count(), 1, "{texts:?}"); + // The convert buttons convert every anchor of the path, one undo step each. + click_button(&mut app, TO_SMOOTH); let a = anchors(&app, line); assert_eq!(a[1].kind, vectorcraft_geom::AnchorKind::Smooth, "{a:?}"); assert!(a[1].has_in() && a[1].has_out(), "{a:?}"); - click(&mut app, "Corner"); + click_button(&mut app, TO_CORNER); assert!(anchors(&app, line).iter().all(|a| !a.has_in() && !a.has_out()), "{:?}", anchors(&app, line)); app.run("edit.undo", json!({})).unwrap(); assert!(anchors(&app, line)[1].has_in(), "Corner undoes in one step"); // Connect closes the open path; a closed one isn't offered it. click(&mut app, "Connect"); assert!(matches!(kind(&app, line), NodeKind::Path { path, .. } if path.is_closed())); - let (_, texts) = bar(&mut app); - assert!(!has(&texts, "Connect") && has(&texts, "Corner"), "{texts:?}"); + let (_, named) = buttons(&mut app); + assert!(!named.iter().any(|(n, _)| n == "Connect") && named.iter().any(|(n, _)| n == TO_CORNER), "{named:?}"); +} + +/// With the Direct Selection tool, the bar's Smooth smooths the path's curvature: it opens the +/// Smooth slider, as Object › Path › Smooth… does, and changes nothing until the slider is +/// confirmed (#6784). Simplify opens its slider in the same way. +#[test] +fn direct_selection_smooth_opens_the_smooth_slider() { + for (name, kind) in [("Smooth", crate::dialogs::smooth_slider::KIND), ("Simplify", crate::dialogs::simplify_slider::KIND)] { + let mut app = app(); + // A jagged line of 40 anchor points, which Smooth changes at its opening level. + let anchors_in: Vec<_> = (0..40).map(|i| json!({"x": 50 + i * 5, "y": 150 + (i % 2) * 6})).collect(); + let line = id(app.run("path.create", json!({"anchors": anchors_in})).unwrap()); + app.run("tool.select", json!({"tool": "directSelection"})).unwrap(); + select(&mut app, &[line]); + let before = anchors(&app, line); + let undo = app.session.active().unwrap().history.undo.len(); + click_button(&mut app, name); + let d = app.ui.dialog.as_ref().unwrap_or_else(|| panic!("{name} opens its slider: {}", app.ui.status)); + assert_eq!(d.kind, kind, "{name}"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "{name} adds no undo step before it is confirmed"); + crate::dialogs::cancel(&mut app); + assert_eq!(anchors(&app, line), before, "{name}: canceling leaves the path as it was"); + if name != "Smooth" { + continue; + } + // Confirmed, the slider smooths the path as one undo step, which is not the anchor convert. + click_button(&mut app, name); + crate::dialogs::confirm(&mut app).unwrap(); + let history = &app.session.active().unwrap().history.undo; + assert_eq!(history.len(), undo + 1, "one undo step"); + assert_ne!(history.last().unwrap().label, "Convert Anchor Points"); + assert_ne!(anchors(&app, line), before, "Smooth changes the path's curvature"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(anchors(&app, line), before, "undo restores the path"); + } } /// Anchors picked out with the Direct Selection tool keep the full set: Remove and Cut need them. @@ -323,11 +399,12 @@ fn direct_selected_anchors_still_offer_remove_and_cut() { let line = id(app.run("path.create", json!({"anchors": [{"x": 50, "y": 200}, {"x": 150, "y": 80}, {"x": 250, "y": 200}]})).unwrap()); app.run("tool.select", json!({"tool": "directSelection"})).unwrap(); app.run("select.anchors", json!({"id": line, "anchors": [[0, 1]]})).unwrap(); - let (_, texts) = bar(&mut app); - for label in ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth"] { - assert!(has(&texts, label), "{label}: {texts:?}"); + let (_, named) = buttons(&mut app); + let names: Vec<&str> = named.iter().map(|(n, _)| n.as_str()).collect(); + for name in ["Remove Anchor Points", "Connect", "Cut Path", TO_CORNER, TO_SMOOTH] { + assert!(names.contains(&name), "{name}: {names:?}"); } - assert!(!has(&texts, "Edit Path"), "{texts:?}"); + assert!(!names.contains(&"Edit Path"), "{names:?}"); } /// Click the task bar's button left of More Options (Lock, or a basic shape's Shape Properties), diff --git a/docs/mcp.md b/docs/mcp.md index 6bebc86bab..2d2e9cc6bc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5156,9 +5156,10 @@ round, such as a rectangle, stays as it is. Closed paths stay closed and open pa {"name":"run_command","arguments":{"command":"object.path.smooth","params":{"level":80}}} ``` -In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a -path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at -the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open +In the desktop app, Object › Path › Smooth, the canvas context menu's Smooth… (listed while the selection holds a +path) and the Contextual Task Bar's Smooth (with a path selected whole with the Direct Selection tool) open the +slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at the +Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open slider back to the Auto-Smooth level. ## Editing a path with the Curvature tool diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7fdc7ef81f..0efc5b77ce 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -845,7 +845,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - While a Type tool (Type, Area Type, Type on a Path and their vertical versions) adds or edits type, the contextual task bar shows the font family menu, the font style menu, the Font Size combo and the Fill chip (); the Touch Type tool shows no bar, as the same page describes. Type selected with a selection tool shows the same three font controls beside its Create Outlines, conversion and Type on a Path items (). The controls act as the Character panel's do: on the characters a Type tool selects, else on every selected type object, with the size left blank where the sizes differ. VectorCraft's own rules: a Type tool's bar leaves out the object actions, the Stroke chip, Recolor Artwork and Lock; a Type tool shows no bar before it adds type or with other art selected; type selected with other art shows no font controls; the font controls go on a second row when one row would be wider than the canvas, and narrow to fit a canvas narrower than they are; while a Type tool edits type, the Fill chip shows the edited characters' fill ("?" where it differs). Evidence: `the_type_tools_show_font_and_fill_controls`, `the_type_tools_show_no_bar_without_type`, `selected_type_shows_font_controls_beside_its_actions`, `the_task_bars_font_size_sets_what_the_character_panel_would`, `the_type_tools_fill_chip_activates_the_fill`, `the_type_tools_bar_is_named_and_stays_on_the_canvas`, `selected_types_font_controls_take_a_second_row_on_a_narrow_canvas` and `the_type_tools_fill_chip_shows_the_edited_characters_fill` in `crates/ui-egui/src/tests/taskbar.rs`. - With a live rectangle, rounded rectangle, ellipse or polygon selected with the Selection tool, the contextual task bar shows a Shape Properties button in place of Lock. It opens a popup with the shape's rows from the Transform panel (corner radius, pie angles, or sides, angle, corner and radius), which edit it through `object.setLiveShape`. The overview describes this: when a shape drawn with a shape tool (Rectangle, Star) is selected, the bar offers to change its properties where Lock Object would be ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Object › Lock and ⌘2 still lock the shape. Not done: stars, which the app draws as plain paths. Evidence: `a_basic_shape_shows_its_properties_in_place_of_lock`, `the_shape_properties_popup_edits_the_shape` and `other_selections_keep_lock` in `crates/ui-egui/src/tests/taskbar.rs`. - The contextual task bar has a Stroke chip beside the Fill chip, drawn with the paint the Stroke proxy shows (a "?" when the selected objects' strokes differ, as on the Fill chip); a click brings the Stroke proxy forward and shows the Swatches panel wherever it is docked, floating or closed, as a click on the Fill chip does for the Fill proxy. The reference app's bar has Fill and Stroke controls for changing an object's colors ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Evidence: `the_task_bar_shows_a_stroke_chip_beside_fill` and `the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere` in `crates/ui-egui/src/canvas/tests.rs`. - - With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, Corner and Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons. As in Illustrator, whose bar has items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Not done: Simplify, which the bar offers only while no anchors are selected, and the bar's Smooth item that adjusts a path's curvature, a command the app doesn't have (Object › Path › Smooth). + - With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, and Convert Selected Anchor Points to Corner and to Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons; the two convert buttons show their icons alone, named by their tool tips as on the Control bar (VectorCraft's own choice). The overview lists items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). With a path selected whole with Direct Selection, the bar also offers Simplify and Smooth, which open the Simplify and Smooth sliders as Object › Path › Simplify… and Smooth… do; the same overview names Smooth as adjusting the path's curvature (#6784). VectorCraft's own rule: while anchors are picked out, the bar offers the anchor items without Simplify and Smooth. Evidence: `direct_selection_smooth_opens_the_smooth_slider` in `crates/ui-egui/src/tests/taskbar.rs`. - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. - Screen readers get a name for the Tools panel's tool buttons, flyout rows, floating strips and tear-off bars, and for the icon buttons, text buttons, toggles, checkboxes, radio buttons, tabs, chips, sliders, angle dials and combo boxes that panels and dialogs draw with `crates/ui-egui/src/widgets.rs`: the text, label or tool tip each one shows, or a name of its own where it shows none (the angle dial, the spinner, the preset arrow, the color wheel and the Reference Point's squares). The tool buttons, toggles, checkboxes, radio buttons, tabs and the Reference Point's nine squares also report whether they are on, an icon button that is lit, such as the chosen Align To button, reports that it is pressed, and the angle dial reports its angle. The status bar's First, Previous, Next and Last Artboard buttons have tool tips. - Tab does not stop on the regions that only the pointer uses: the custom title bar's background and the window's resize zones, the band at the top of a dialog that moves it, the Tools panel's grip, a flyout's and a floating strip's tear-off bar, the isolation bar's background, and the rulers and the box where they meet. From 99773c1d7aecec40a2c89873f2899942ba460ec2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:00:50 -0800 Subject: [PATCH 3/9] Actions: Load Actions reports steps with unknown command ids A step whose command id is not a built-in command, a command of an installed plug-in, a View menu step or a stop now loads as it is and is reported when the set loads: the ui.actionSet.load answer lists the action, step and id (unknownCommands, at most 1,000, and unknownCount), and the status bar names the first and the count. Commands inside a command.batch step are checked one by one. Control characters in the status text are replaced and long names are cut at 80 characters. An action with such a step included no longer plays: the error names the step and its id before any step runs, at every speed. An excluded step does not stop it. --- crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tests.rs | 4 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/actions.rs | 186 ++++++++++++++++++++- crates/ui-egui/src/panels/actions/files.rs | 50 +++++- docs/mcp.md | 16 +- docs/target-app-parity.md | 1 + 28 files changed, 296 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 6c3aa46c6d..58d2b0c4e7 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5829,6 +5829,7 @@ digits Malayalam Malaiàlam @msg no action set to save no hi ha cap conjunt d’accions per desar @msg the action set file can't be read in full el fitxer de conjunt d’accions no es pot llegir sencer @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out S’ha carregat “{name}”: s’ha omès {n} pas|S’ha carregat “{name}”: s’han omès {n} passos +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` S’ha carregat “{name}”: {n} pas té una ordre desconeguda i s’ha conservat: “{action}”, pas {step}, `{command}`|S’ha carregat “{name}”: {n} passos tenen ordres desconegudes i s’han conservat; el primer: “{action}”, pas {step}, `{command}` @msg not an action set file no és un fitxer de conjunt d’accions @msg the action set file is larger than 8 MiB el fitxer de conjunt d’accions supera els 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referència superaria els {MAX_BYTES} bytes @@ -6319,6 +6320,7 @@ digits Malayalam Malaiàlam @msg truncated truncat @msg Unembedded to {_1} S’ha desincrustat a {_1} @msg unknown command `{0}` ordre desconeguda `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` el pas {step} de “{action}” executa l’ordre desconeguda `{command}` @msg unknown command `{0}`; did you mean `{1}`? ordre desconeguda `{0}`; volies dir `{1}`? @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode control desconegut `{name}`: fillStroke, coloring, drawMode o screenMode @msg Unknown dimension mode Mode de dimensió desconegut diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a80d4f6b2c..87fbf19781 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5013,6 +5013,7 @@ imageTrace Outline Obrys @msg no action set to save žádná sada akcí k uložení @msg the action set file can't be read in full soubor sady akcí nelze přečíst celý @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Načteno „{name}“: vynechán {n} krok|Načteno „{name}“: vynechány {n} kroky|Načteno „{name}“: vynecháno {n} kroků +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Načteno „{name}“: {n} krok má neznámý příkaz a byl ponechán: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroky mají neznámé příkazy a byly ponechány; první: „{action}“, krok {step}, `{command}`|Načteno „{name}“: {n} kroků má neznámé příkazy a bylo ponecháno; první: „{action}“, krok {step}, `{command}` @msg not an action set file není to soubor sady akcí @msg the action set file is larger than 8 MiB soubor sady akcí je větší než 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: reference by byla větší než {MAX_BYTES} bajtů @@ -5253,6 +5254,7 @@ imageTrace Outline Obrys @msg tool shortcuts must be a single key (optionally with Shift) zkratky nástrojů musí být jediná klávesa (volitelně se Shift) @msg truncated zkráceno @msg unknown command `{0}` neznámý příkaz `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} akce „{action}“ spouští neznámý příkaz `{command}` @msg unknown command `{0}`; did you mean `{1}`? neznámý příkaz `{0}`; mysleli jste `{1}`? @msg unknown effect `{id}` neznámý efekt `{id}` @msg unknown format neznámý formát diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3dc6542d44..3ffc857b4d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4628,6 +4628,7 @@ theme Appearance Erscheinungsbild @msg no action set to save kein Aktionssatz zum Speichern @msg the action set file can't be read in full die Aktionssatzdatei kann nicht vollständig gelesen werden @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}“ geladen: {n} Schritt wurde ausgelassen|„{name}“ geladen: {n} Schritte wurden ausgelassen +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}“ geladen: {n} Schritt hat einen unbekannten Befehl und wurde behalten: „{action}“, Schritt {step}, `{command}`|„{name}“ geladen: {n} Schritte haben unbekannte Befehle und wurden behalten; der erste: „{action}“, Schritt {step}, `{command}` @msg not an action set file keine Aktionssatzdatei @msg the action set file is larger than 8 MiB die Aktionssatzdatei ist größer als 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: die Referenz wäre größer als {MAX_BYTES} Byte @@ -4869,6 +4870,7 @@ theme Appearance Erscheinungsbild @msg tool shortcuts must be a single key (optionally with Shift) Tastaturbefehle für Werkzeuge müssen aus einer einzelnen Taste bestehen (optional mit Shift) @msg truncated abgeschnitten @msg unknown command `{0}` unbekannter Befehl `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` Schritt {step} von „{action}“ führt den unbekannten Befehl `{command}` aus @msg unknown command `{0}`; did you mean `{1}`? unbekannter Befehl `{0}`; meinten Sie `{1}`? @msg unknown effect `{id}` unbekannter Effekt `{id}` @msg unknown format unbekanntes Format diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 95158fc54c..7a38da4285 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4660,6 +4660,7 @@ digits Malayalam Μαλαγιάλαμ @msg no action set to save δεν υπάρχει σύνολο ενεργειών για αποθήκευση @msg the action set file can't be read in full το αρχείο συνόλου ενεργειών δεν μπορεί να διαβαστεί ολόκληρο @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Φορτώθηκε το «{name}»: παραλείφθηκε {n} βήμα|Φορτώθηκε το «{name}»: παραλείφθηκαν {n} βήματα +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Φορτώθηκε το «{name}»: {n} βήμα έχει άγνωστη εντολή και διατηρήθηκε: «{action}», βήμα {step}, `{command}`|Φορτώθηκε το «{name}»: {n} βήματα έχουν άγνωστες εντολές και διατηρήθηκαν· το πρώτο: «{action}», βήμα {step}, `{command}` @msg not an action set file δεν είναι αρχείο συνόλου ενεργειών @msg the action set file is larger than 8 MiB το αρχείο συνόλου ενεργειών είναι μεγαλύτερο από 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: η αναφορά θα ήταν μεγαλύτερη από {MAX_BYTES} byte @@ -4894,6 +4895,7 @@ digits Malayalam Μαλαγιάλαμ @msg tool shortcuts must be a single key (optionally with Shift) οι συντομεύσεις εργαλείων πρέπει να περιλαμβάνουν ένα πλήκτρο (προαιρετικά με Shift) @msg truncated περικομμένο @msg unknown command `{0}` άγνωστη εντολή `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` το βήμα {step} της ενέργειας «{action}» εκτελεί την άγνωστη εντολή `{command}` @msg unknown command `{0}`; did you mean `{1}`? άγνωστη εντολή `{0}`· μήπως εννοείτε `{1}`; @msg unknown effect `{id}` άγνωστο εφέ `{id}` @msg unknown format άγνωστη μορφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7041dcacb3..d7adb8d004 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4704,6 +4704,7 @@ axis Both Ambos @msg no action set to save no hay ningún conjunto de acciones que guardar @msg the action set file can't be read in full el archivo de conjunto de acciones no se puede leer completo @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Se cargó “{name}”: se omitió {n} paso|Se cargó “{name}”: se omitieron {n} pasos +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Se cargó “{name}”: {n} paso tiene un comando desconocido y se conservó: “{action}”, paso {step}, `{command}`|Se cargó “{name}”: {n} pasos tienen comandos desconocidos y se conservaron; el primero: “{action}”, paso {step}, `{command}` @msg not an action set file no es un archivo de conjunto de acciones @msg the action set file is larger than 8 MiB el archivo de conjunto de acciones supera los 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: la referencia superaría los {MAX_BYTES} bytes @@ -4945,6 +4946,7 @@ axis Both Ambos @msg tool shortcuts must be a single key (optionally with Shift) los atajos de herramientas deben ser una sola tecla (opcionalmente con Shift) @msg truncated truncado @msg unknown command `{0}` comando desconocido `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` el paso {step} de “{action}” ejecuta el comando desconocido `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando desconocido `{0}`; ¿quiso decir `{1}`? @msg unknown effect `{id}` efecto desconocido `{id}` @msg unknown format formato desconocido diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c5f1bd4d48..60487a0fa0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4919,6 +4919,7 @@ imageTrace Outline Ääriviiva @msg no action set to save ei tallennettavaa toimintosarjaa @msg the action set file can't be read in full toimintosarjatiedostoa ei voi lukea kokonaan @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Ladattiin ”{name}”: jätettiin pois {n} vaihe|Ladattiin ”{name}”: jätettiin pois {n} vaihetta +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Ladattiin ”{name}”: {n} vaiheella on tuntematon komento, ja se säilytettiin: ”{action}”, vaihe {step}, `{command}`|Ladattiin ”{name}”: {n} vaiheella on tuntemattomia komentoja, ja ne säilytettiin; ensimmäinen: ”{action}”, vaihe {step}, `{command}` @msg not an action set file ei ole toimintosarjatiedosto @msg the action set file is larger than 8 MiB toimintosarjatiedosto on suurempi kuin 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: viite olisi suurempi kuin {MAX_BYTES} tavua @@ -5152,6 +5153,7 @@ imageTrace Outline Ääriviiva @msg tool shortcuts must be a single key (optionally with Shift) työkalujen pikanäppäinten on oltava yksittäinen näppäin (valinnaisesti Vaihto-näppäimen kanssa) @msg truncated katkaistu @msg unknown command `{0}` tuntematon komento `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` toiminnon ”{action}” vaihe {step} suorittaa tuntemattoman komennon `{command}` @msg unknown command `{0}`; did you mean `{1}`? tuntematon komento `{0}`; tarkoititko `{1}`? @msg unknown effect `{id}` tuntematon tehoste `{id}` @msg unknown format tuntematon muoto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4227df5602..9f5a19a16b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4693,6 +4693,7 @@ axis Both Les deux @msg no action set to save aucun jeu de scripts à enregistrer @msg the action set file can't be read in full le fichier de jeu de scripts ne peut pas être lu en entier @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out « {name} » chargé : {n} étape a été ignorée|« {name} » chargé : {n} étapes ont été ignorées +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` « {name} » chargé : {n} étape a une commande inconnue et a été conservée : « {action} », étape {step}, `{command}`|« {name} » chargé : {n} étapes ont des commandes inconnues et ont été conservées ; la première : « {action} », étape {step}, `{command}` @msg not an action set file ce n’est pas un fichier de jeu de scripts @msg the action set file is larger than 8 MiB le fichier de jeu de scripts dépasse 8 Mio @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}` : la référence dépasserait {MAX_BYTES} octets @@ -4934,6 +4935,7 @@ axis Both Les deux @msg tool shortcuts must be a single key (optionally with Shift) les raccourcis d’outils doivent être une seule touche (éventuellement avec Maj) @msg truncated tronqué @msg unknown command `{0}` commande inconnue `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` l’étape {step} de « {action} » exécute la commande inconnue `{command}` @msg unknown command `{0}`; did you mean `{1}`? commande inconnue `{0}` ; vouliez-vous dire `{1}` ? @msg unknown effect `{id}` effet inconnu `{id}` @msg unknown format format inconnu diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3b8986e5f1..bdbbd40674 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7474,6 +7474,7 @@ antiAlias Sharp शार्प @msg not a Word document: its WordDocument stream doesn't start with a file information block वर्ड डॉक्यूमेंट नहीं: इसकी WordDocument धारा फ़ाइल सूचना ब्लॉक के साथ शुरू नहीं होती है @msg the action set file can't be read in full एक्शन सेट फ़ाइल पूरी तरह नहीं पढ़ी जा सकती @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” लोड किया गया: {n} चरण में अज्ञात आदेश है और उसे रखा गया: “{action}”, चरण {step}, `{command}`|“{name}” लोड किया गया: {n} चरणों में अज्ञात आदेश हैं और उन्हें रखा गया; पहला: “{action}”, चरण {step}, `{command}` @msg not an action set file एक एक्शन सेट फ़ाइल नहीं @msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल @msg not an HP-GL file HP-GL फ़ाइल नहीं है @@ -8362,6 +8363,7 @@ antiAlias Sharp शार्प @msg unknown character style number field अज्ञात चरित्र स्टाइल संख्या क्षेत्र @msg unknown CMYK profile `{name}` अज्ञात CMYK प्रोफ़ाइल `{name}` @msg unknown command `{0}` अज्ञात आदेश `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” का चरण {step} अज्ञात आदेश `{command}` चलाता है @msg unknown command `{0}`; did you mean `{1}`? अज्ञात आदेश `{0}`; क्या आपको `{1}` का मतलब है? @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode अज्ञात नियंत्रण `{name}`: fillStroke, रंग, drawMode या screenMode @msg Unknown dimension mode अज्ञात आयाम मोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cea96d098d..4a77763b65 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5390,6 +5390,7 @@ imageTrace Outline Körvonal @msg no action set to save nincs menthető műveletkészlet @msg the action set file can't be read in full a műveletkészlet-fájl nem olvasható be teljesen @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” betöltve; kihagyott lépések: {n} +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}” betöltve; megtartott lépések ismeretlen paranccsal: {n}; az első: „{action}”, lépés: {step}, `{command}` @msg not an action set file nem műveletkészlet-fájl @msg the action set file is larger than 8 MiB a műveletkészlet-fájl nagyobb 8 MiB-nál @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referencia nagyobb lenne {MAX_BYTES} bájtnál @@ -5762,6 +5763,7 @@ imageTrace Outline Körvonal @msg truncated csonka @msg Unembedded to {_1} Beágyazás megszüntetve, ide: {_1} @msg unknown command `{0}` ismeretlen parancs: `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` „{action}”, lépés: {step}: ismeretlen parancsot futtat: `{command}` @msg unknown command `{0}`; did you mean `{1}`? ismeretlen parancs: `{0}`; erre gondolt: `{1}`? @msg unknown effect `{id}` ismeretlen effektus: `{id}` @msg unknown format ismeretlen formátum diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 61ad0afce3..cb3a6b2a84 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4756,6 +4756,7 @@ axis Both Entrambi @msg no action set to save nessun set di azioni da salvare @msg the action set file can't be read in full il file di set di azioni non può essere letto per intero @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Caricato “{name}”: {n} passaggio è stato omesso|Caricato “{name}”: {n} passaggi sono stati omessi +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Caricato “{name}”: {n} passaggio ha un comando sconosciuto ed è stato mantenuto: “{action}”, passaggio {step}, `{command}`|Caricato “{name}”: {n} passaggi hanno comandi sconosciuti e sono stati mantenuti; il primo: “{action}”, passaggio {step}, `{command}` @msg not an action set file non è un file di set di azioni @msg the action set file is larger than 8 MiB il file del set di azioni supera 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: il riferimento supererebbe {MAX_BYTES} byte @@ -4997,6 +4998,7 @@ axis Both Entrambi @msg tool shortcuts must be a single key (optionally with Shift) le scelte rapide degli strumenti devono essere un singolo tasto (eventualmente con Shift) @msg truncated troncato @msg unknown command `{0}` comando sconosciuto `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` il passaggio {step} di “{action}” esegue il comando sconosciuto `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando sconosciuto `{0}`; forse intendevate `{1}`? @msg unknown effect `{id}` effetto sconosciuto `{id}` @msg unknown format formato sconosciuto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e624f78e06..2c43c9ed9f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4680,6 +4680,7 @@ swatch Bright Violet ビビッドバイオレット @msg objects can't be nested more than 32 deep オブジェクトは 32 階層より深く入れ子にできません @msg the SVG's elements are nested more than {0} deep SVG の要素が {0} 階層より深く入れ子になっています @msg unknown command `{0}` `{0}` は不明なコマンドです +@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」のステップ {step} は不明なコマンド `{command}` を実行します @msg unknown command `{0}`; did you mean `{1}`? `{0}` は不明なコマンドです。`{1}` のことですか? @msg `{what}` closes nothing `{what}` に対応する開始部分がありません @msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています @@ -5025,6 +5026,7 @@ swatch Bright Violet ビビッドバイオレット @msg no action set to save 保存するアクションセットがありません @msg the action set file can't be read in full アクションセットファイルをすべて読み込めません @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 「{name}」を読み込みました。{n} 個のステップは省きました +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 「{name}」を読み込みました。不明なコマンドのステップ {n} 個はそのまま残しました。最初は「{action}」のステップ {step}、`{command}` です @msg not an action set file アクションセットファイルではありません @msg the action set file is larger than 8 MiB アクションセットファイルが 8 MiB を超えています @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: リファレンスが {MAX_BYTES} バイトを超えます diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 237b034f61..1e480b0c42 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4386,6 +4386,7 @@ font-technology Variable ცვლადი @msg no action set to save შესანახი მოქმედებების ნაკრები არ არის @msg the action set file can't be read in full მოქმედებების ნაკრების ფაილის სრულად წაკითხვა შეუძლებელია @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out ჩაიტვირთა „{name}“; გამოტოვებული ნაბიჯები: {n} +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` ჩაიტვირთა „{name}“; შენახული ნაბიჯები უცნობი ბრძანებით: {n}; პირველი: „{action}“, ნაბიჯი {step}, `{command}` @msg not an action set file ეს არ არის მოქმედებების ნაკრების ფაილი @msg the action set file is larger than 8 MiB მოქმედებების ნაკრების ფაილი 8 MiB-ზე დიდია @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: ცნობარი {MAX_BYTES} ბაიტზე დიდი იქნებოდა @@ -4605,6 +4606,7 @@ font-technology Variable ცვლადი @msg tool shortcuts must be a single key (optionally with Shift) ხელსაწყოს მალსახმობი ერთი კლავიში უნდა იყოს (სურვილისამებრ Shift-ით) @msg truncated შეკვეცილია @msg unknown command `{0}` უცნობი ბრძანება `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` „{action}“, ნაბიჯი {step}: უცნობი ბრძანება `{command}` @msg unknown command `{0}`; did you mean `{1}`? უცნობი ბრძანება `{0}`; იქნებ `{1}` იგულისხმეთ? @msg unknown effect `{id}` უცნობი ეფექტი `{id}` @msg unknown format უცნობი ფორმატი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 06e3e47e52..719d8242bc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4607,6 +4607,7 @@ theme Appearance 외관 @msg objects can't be nested more than 32 deep 개체는 32단계보다 깊게 중첩할 수 없습니다 @msg the SVG's elements are nested more than {0} deep SVG 요소가 {0}단계보다 깊게 중첩되어 있습니다 @msg unknown command `{0}` 알 수 없는 명령 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”의 단계 {step}에서 알 수 없는 명령 `{command}`을(를) 실행합니다 @msg unknown command `{0}`; did you mean `{1}`? 알 수 없는 명령 `{0}`: `{1}`을(를) 의미하셨습니까? @msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다 @msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다 @@ -4952,6 +4953,7 @@ theme Appearance 외관 @msg no action set to save 저장할 액션 세트가 없습니다 @msg the action set file can't be read in full 액션 세트 파일을 전부 읽을 수 없습니다 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}”을(를) 불러왔습니다. 단계 {n}개를 제외했습니다 +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}”을(를) 불러왔습니다. 알 수 없는 명령이 있는 단계 {n}개를 그대로 두었습니다. 첫 번째: “{action}”, 단계 {step}, `{command}` @msg not an action set file 액션 세트 파일이 아닙니다 @msg the action set file is larger than 8 MiB 액션 세트 파일이 8 MiB보다 큽니다 @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: 참조가 {MAX_BYTES}바이트보다 커집니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 8b91f8f3b9..ecd89254ab 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5073,6 +5073,7 @@ imageTrace Outline Contour @msg no action set to save geen actieset om op te slaan @msg the action set file can't be read in full het actiesetbestand kan niet volledig worden gelezen @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out „{name}” geladen: {n} stap is weggelaten|„{name}” geladen: {n} stappen zijn weggelaten +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` „{name}” geladen: {n} stap heeft een onbekende opdracht en is behouden: „{action}”, stap {step}, `{command}`|„{name}” geladen: {n} stappen hebben onbekende opdrachten en zijn behouden; de eerste: „{action}”, stap {step}, `{command}` @msg not an action set file geen actiesetbestand @msg the action set file is larger than 8 MiB het actiesetbestand is groter dan 8 MiB @msg the action set file holds more than 1,000 actions het actiesetbestand bevat meer dan 1.000 acties @@ -5274,6 +5275,7 @@ imageTrace Outline Contour @msg tool shortcuts must be a single key (optionally with Shift) gereedschapsneltoetsen moeten uit één toets bestaan (eventueel met Shift) @msg truncated afgekapt @msg unknown command `{0}` onbekende opdracht `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` stap {step} van „{action}” voert de onbekende opdracht `{command}` uit @msg unknown command `{0}`; did you mean `{1}`? onbekende opdracht `{0}`; bedoelde je `{1}`? @msg unknown effect `{id}` onbekend effect `{id}` @msg unknown format onbekende indeling diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ca34501a93..94d2bbe257 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4837,6 +4837,7 @@ imageTrace Outline Kontur @msg no action set to save brak zestawu operacji do zapisania @msg the action set file can't be read in full nie można odczytać całego pliku zestawu operacji @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Wczytano „{name}”: pominięto {n} krok|Wczytano „{name}”: pominięto {n} kroki|Wczytano „{name}”: pominięto {n} kroków +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Wczytano „{name}”: {n} krok ma nieznane polecenie i został zachowany: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroki mają nieznane polecenia i zostały zachowane; pierwszy: „{action}”, krok {step}, `{command}`|Wczytano „{name}”: {n} kroków ma nieznane polecenia i zostało zachowanych; pierwszy: „{action}”, krok {step}, `{command}` @msg not an action set file to nie jest plik zestawu operacji @msg the action set file is larger than 8 MiB plik zestawu operacji jest większy niż 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: dokumentacja byłaby większa niż {MAX_BYTES} bajtów @@ -5080,6 +5081,7 @@ imageTrace Outline Kontur @msg tool shortcuts must be a single key (optionally with Shift) skróty narzędzi muszą być pojedynczym klawiszem (opcjonalnie z Shift) @msg truncated obcięty @msg unknown command `{0}` nieznane polecenie `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` krok {step} operacji „{action}” uruchamia nieznane polecenie `{command}` @msg unknown command `{0}`; did you mean `{1}`? nieznane polecenie `{0}`; czy chodziło o `{1}`? @msg unknown effect `{id}` nieznany efekt `{id}` @msg unknown format nieznany format diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6006f84c90..71e997eb67 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4162,6 +4162,7 @@ axis Both Ambos @msg no action set to save nenhum conjunto de ações para salvar @msg the action set file can't be read in full o arquivo de conjunto de ações não pode ser lido por inteiro @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” carregado: {n} etapa foi omitida|“{name}” carregado: {n} etapas foram omitidas +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” carregado: {n} etapa tem um comando desconhecido e foi mantida: “{action}”, etapa {step}, `{command}`|“{name}” carregado: {n} etapas têm comandos desconhecidos e foram mantidas; a primeira: “{action}”, etapa {step}, `{command}` @msg not an action set file não é um arquivo de conjunto de ações @msg the action set file is larger than 8 MiB o arquivo de conjunto de ações tem mais de 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: a referência teria mais de {MAX_BYTES} bytes @@ -4401,6 +4402,7 @@ axis Both Ambos @msg tool shortcuts must be a single key (optionally with Shift) os atalhos de ferramenta devem ser uma única tecla (opcionalmente com Shift) @msg truncated truncado @msg unknown command `{0}` comando desconhecido `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` a etapa {step} de “{action}” executa o comando desconhecido `{command}` @msg unknown command `{0}`; did you mean `{1}`? comando desconhecido `{0}`; você quis dizer `{1}`? @msg unknown effect `{id}` efeito desconhecido `{id}` @msg unknown format formato desconhecido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fd09504b42..27b940845f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4741,6 +4741,7 @@ axis Both Оба @msg no action set to save нет набора действий для сохранения @msg the action set file can't be read in full файл набора действий невозможно прочитать целиком @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Загружено «{name}»: пропущен {n} шаг|Загружено «{name}»: пропущено {n} шага|Загружено «{name}»: пропущено {n} шагов +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Загружено «{name}»: {n} шаг с неизвестной командой сохранён: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шага с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}`|Загружено «{name}»: {n} шагов с неизвестными командами сохранены; первый: «{action}», шаг {step}, `{command}` @msg not an action set file это не файл набора действий @msg the action set file is larger than 8 MiB файл набора действий больше 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: справочник был бы больше {MAX_BYTES} байт @@ -4982,6 +4983,7 @@ axis Both Оба @msg tool shortcuts must be a single key (optionally with Shift) сочетания инструментов должны быть одной клавишей (необязательно с Shift) @msg truncated усечено @msg unknown command `{0}` неизвестная команда `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` шаг {step} действия «{action}» выполняет неизвестную команду `{command}` @msg unknown command `{0}`; did you mean `{1}`? неизвестная команда `{0}`; возможно, имелась в виду `{1}`? @msg unknown effect `{id}` неизвестный эффект `{id}` @msg unknown format неизвестный формат diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4e7b7508ae..d925511b03 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4605,6 +4605,7 @@ axis Both Båda @msg no action set to save ingen åtgärdsuppsättning att spara @msg the action set file can't be read in full filen med åtgärdsuppsättning kan inte läsas helt @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Läste in ”{name}”: {n} steg utelämnades|Läste in ”{name}”: {n} steg utelämnades +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Läste in ”{name}”: {n} steg har ett okänt kommando och behölls: ”{action}”, steg {step}, `{command}`|Läste in ”{name}”: {n} steg har okända kommandon och behölls; det första: ”{action}”, steg {step}, `{command}` @msg not an action set file inte en fil med åtgärdsuppsättning @msg the action set file is larger than 8 MiB filen med åtgärdsuppsättningen är större än 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: referensen skulle bli större än {MAX_BYTES} byte @@ -4839,6 +4840,7 @@ axis Both Båda @msg tool shortcuts must be a single key (optionally with Shift) kortkommandon för verktyg måste vara en enda tangent (eventuellt med Shift) @msg truncated avkortad @msg unknown command `{0}` okänt kommando `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` steg {step} i ”{action}” kör det okända kommandot `{command}` @msg unknown command `{0}`; did you mean `{1}`? okänt kommando `{0}`; menade du `{1}`? @msg unknown effect `{id}` okänd effekt `{id}` @msg unknown format okänt format diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 54b967701d..92557b6c96 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2302,6 +2302,10 @@ const COUNTED_STATUS_MESSAGES: &[(&str, &str)] = &[ ("Downloaded 1 file", "Downloaded 2 files"), ("Exported 1 file to /out", "Exported 2 files to /out"), ("Loaded “Tidy”: 1 step was left out", "Loaded “Tidy”: 2 steps were left out"), + ( + "Loaded “Tidy”: 1 step has an unknown command and was kept: “Export”, step 4, `example.finish`", + "Loaded “Tidy”: 2 steps have unknown commands and were kept; the first: “Export”, step 4.2, `example.finish`", + ), ]; /// The link, font, export and action set status messages name a count of 1 in the singular and other counts in diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ac23f62c74..abe5ae7c16 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4464,6 +4464,7 @@ axis Both Her İkisi @msg no action set to save kaydedilecek eylem seti yok @msg the action set file can't be read in full eylem seti dosyası tamamen okunamıyor @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” yüklendi: {n} adım atlandı +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` “{name}” yüklendi: bilinmeyen komutlu {n} adım korundu; ilki: “{action}”, adım {step}, `{command}` @msg not an action set file bir eylem seti dosyası değil @msg the action set file is larger than 8 MiB eylem seti dosyası 8 MiB'den büyük @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: başvuru {MAX_BYTES} bayttan büyük olur @@ -4687,6 +4688,7 @@ axis Both Her İkisi @msg tool shortcuts must be a single key (optionally with Shift) araç kısayolları tek tuş olmalıdır (isteğe bağlı olarak Shift ile) @msg truncated kesik @msg unknown command `{0}` bilinmeyen komut `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}” eylemi, adım {step}: bilinmeyen `{command}` komutunu çalıştırıyor @msg unknown command `{0}`; did you mean `{1}`? bilinmeyen komut `{0}`; `{1}` mi demek istediniz? @msg unknown effect `{id}` bilinmeyen etki `{id}` @msg unknown format bilinmeyen format diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ce8e8dcef6..0c78d9c359 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4740,6 +4740,7 @@ axis Both Обидві @msg no action set to save немає набору дій для збереження @msg the action set file can't be read in full файл набору дій неможливо прочитати повністю @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Завантажено «{name}»: пропущено {n} крок|Завантажено «{name}»: пропущено {n} кроки|Завантажено «{name}»: пропущено {n} кроків +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Завантажено «{name}»: {n} крок із невідомою командою збережено: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроки з невідомими командами збережено; перший: «{action}», крок {step}, `{command}`|Завантажено «{name}»: {n} кроків із невідомими командами збережено; перший: «{action}», крок {step}, `{command}` @msg not an action set file це не файл набору дій @msg the action set file is larger than 8 MiB файл набору дій більший за 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: довідник був би більшим за {MAX_BYTES} байтів @@ -4981,6 +4982,7 @@ axis Both Обидві @msg tool shortcuts must be a single key (optionally with Shift) поєднання інструментів має бути однією клавішею (необов’язково з Shift) @msg truncated усічено @msg unknown command `{0}` невідома команда `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` крок {step} дії «{action}» виконує невідому команду `{command}` @msg unknown command `{0}`; did you mean `{1}`? невідома команда `{0}`; можливо, ви мали на увазі `{1}`? @msg unknown effect `{id}` невідомий ефект `{id}` @msg unknown format невідомий формат diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 88d0185f95..2c98e16c73 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5050,6 +5050,7 @@ theme Appearance Giao diện @msg no action set to save không có bộ hành động nào để lưu @msg the action set file can't be read in full không thể đọc hết tệp bộ hành động @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out Đã tải “{name}”: bỏ qua {n} bước +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` Đã tải “{name}”: giữ lại {n} bước có lệnh không xác định; bước đầu tiên: “{action}”, bước {step}, `{command}` @msg not an action set file không phải tệp bộ hành động @msg the action set file is larger than 8 MiB tệp bộ hành động lớn hơn 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`: tài liệu tham khảo sẽ lớn hơn {MAX_BYTES} byte @@ -5490,6 +5491,7 @@ theme Appearance Giao diện @msg truncated bị cắt ngắn @msg Unembedded to {_1} Đã bỏ nhúng vào {_1} @msg unknown command `{0}` lệnh không xác định `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` bước {step} của “{action}” chạy lệnh không xác định `{command}` @msg unknown command `{0}`; did you mean `{1}`? lệnh không xác định `{0}`; ý bạn là `{1}`? @msg unknown effect `{id}` hiệu ứng không xác định `{id}` @msg unknown format định dạng không xác định diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3a5f0ce687..f74c7e0fa5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4375,6 +4375,7 @@ imageTrace Outline 轮廓 @msg objects can't be nested more than 32 deep 对象的嵌套不能超过 32 层 @msg the SVG's elements are nested more than {0} deep SVG 的元素嵌套超过 {0} 层 @msg unknown command `{0}` 未知命令 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` “{action}”的步骤 {step} 运行未知命令 `{command}` @msg unknown command `{0}`; did you mean `{1}`? 未知命令 `{0}`;您是否想输入 `{1}`? @msg `{what}` closes nothing `{what}` 没有可结束的内容 @msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容 @@ -4708,6 +4709,7 @@ imageTrace Outline 轮廓 @msg no action set to save 没有要存储的动作组 @msg the action set file can't be read in full 无法完整读取动作组文件 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已载入“{name}”:已略过 {n} 个步骤 +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 已载入“{name}”:保留了 {n} 个含未知命令的步骤;第一个:“{action}”,步骤 {step},`{command}` @msg not an action set file 不是动作组文件 @msg the action set file is larger than 8 MiB 动作组文件大于 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:参考将大于 {MAX_BYTES} 字节 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e2dd01a4de..5d8663d970 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2116,6 +2116,7 @@ file-filter Templates 範本 @msg objects can't be nested more than 32 deep 物件的巢狀層級不能超過 32 層 @msg the SVG's elements are nested more than {0} deep SVG 的元素巢狀超過 {0} 層 @msg unknown command `{0}` 未知的指令 `{0}` +@msg step {step} of “{action}” runs the unknown command `{command}` 「{action}」的步驟 {step} 執行未知的指令 `{command}` @msg unknown command `{0}`; did you mean `{1}`? 未知的指令 `{0}`;您是指 `{1}` 嗎? @msg `{what}` closes nothing `{what}` 沒有可結束的內容 @msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容 @@ -2452,6 +2453,7 @@ file-filter Templates 範本 @msg no action set to save 沒有要儲存的動作組 @msg the action set file can't be read in full 無法完整讀取動作組檔案 @msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out 已載入「{name}」:已略過 {n} 個步驟 +@msgplural Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`|Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}` 已載入「{name}」:保留了 {n} 個含未知指令的步驟;第一個:「{action}」,步驟 {step},`{command}` @msg not an action set file 不是動作組檔案 @msg the action set file is larger than 8 MiB 動作組檔案大於 8 MiB @msg `{ID}`: the reference would be larger than {MAX_BYTES} bytes `{ID}`:參考將大於 {MAX_BYTES} 位元組 diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 1971834a07..610a1a61b3 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -84,6 +84,70 @@ impl Action { }) .collect() } + + /// The steps whose command `known` does not hold, in order, as (step index, the index of the + /// command in a `command.batch` step, command id). A `command.batch` step's own commands are + /// checked one by one, as playback runs them ([`Action::included_commands`]); a command of one + /// with no command id has the id "". With `included_only`, excluded steps are passed over. + pub(crate) fn unknown_commands<'a>( + &'a self, + known: &'a KnownCommands, + included_only: bool, + ) -> impl Iterator, &'a str)> + 'a { + self.steps.iter().enumerate().filter(move |(i, _)| !(included_only && self.excluded_steps.contains(i))).flat_map( + |(i, (command, params))| -> Vec<(usize, Option, &'a str)> { + match params.get("commands").and_then(Value::as_array) { + Some(steps) if command == "command.batch" => steps + .iter() + .enumerate() + .map(|(j, step)| (i, Some(j), step.get("command").and_then(Value::as_str).unwrap_or_default())) + .filter(|(_, _, id)| !known.has(id)) + .collect(), + _ if known.has(command) => vec![], + _ => vec![(i, None, command.as_str())], + } + }, + ) + } +} + +/// The command ids a step of an action can run now: the built-in engine commands, the commands the +/// installed plug-ins add, the View menu commands the app plays itself ([`crate::menus::plays_in_app`]) +/// and stops ([`STOP_STEP`]). +pub(crate) struct KnownCommands(std::collections::HashSet<&'static str>); + +impl KnownCommands { + /// The command ids known when it is called: a plug-in installed or removed later is not in it. + pub(crate) fn now() -> Self { + let engine = vectorcraft_engine::command_specs().iter().map(|c| c.id); + let plugins = vectorcraft_engine::cmd::plugin::command_specs().into_iter().map(|c| c.id); + Self(engine.chain(plugins).chain([STOP_STEP]).collect()) + } + + /// Whether a step can run command `id`. + pub(crate) fn has(&self, id: &str) -> bool { + self.0.contains(id) || crate::menus::plays_in_app(id) + } +} + +/// The one-based place of a step that [`Action::unknown_commands`] gives: `3` for the third step, +/// `3.2` for the second command of a `command.batch` third step. +pub(crate) fn step_place(step: usize, in_batch: Option) -> String { + match in_batch { + Some(j) => format!("{}.{}", step + 1, j + 1), + None => (step + 1).to_string(), + } +} + +/// `text` as a status message shows it: control characters become `�`, and text past 80 +/// characters ends in `…`. +pub(crate) fn shown_text(text: &str) -> String { + const MAX: usize = 80; + let mut shown: String = text.chars().take(MAX).map(|c| if c.is_control() { '\u{fffd}' } else { c }).collect(); + if text.chars().nth(MAX).is_some() { + shown.push('…'); + } + shown } /// An action id no other action in this run has. @@ -222,7 +286,10 @@ pub fn default_sets() -> Vec { /// Accelerated runs every included step at once as a single undoable batch → what `command.batch` /// returns, {results}. Step by Step and Pause For run the first step now, each as its own undo step, /// and leave the rest to [`tick_playback`] → {results: [the first step's result], pending: the steps -/// left}. While a paced playback is in progress, another action does not play. +/// left}. While a paced playback is in progress, another action does not play. An action with an +/// included step whose command is not known now ([`KnownCommands`]), a plug-in's command after the +/// plug-in was removed for one, does not play: the error names the first such step and its command, +/// and no step runs. An excluded step is not checked. /// /// An action with an included stop ([`STOP_STEP`]) plays up to the stop and waits there at any speed /// ([`continue_playback`]). At Accelerated, the steps between two stops are one undo step. → {results: @@ -240,6 +307,13 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result Result { /// Actions… would write as a file Load Actions… refuses (larger than 8 MiB, as a file with less /// white space or a name that gets a number can give) is refused too, and no set is added. /// +/// A step whose command is not known now ([`super::KnownCommands`]), excluded or not, and a command +/// of a `command.batch` step that is not, still loads as it is. The answer then also lists them, +/// and the status bar names the first and how many there are → `{…, unknownCommands: [{action, +/// actionIndex, step, stepIndex, batchStep?, command}] (at most 1,000), unknownCount: n}`. +/// /// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its /// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), /// with no params; a step that is off in the file is excluded from playback. A set with no name is @@ -506,7 +512,47 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { set.name = free_set_name(&app.ui.action_sets, &set.name); // A number added to the name can make a file at the limit larger than it. super::edit_keeps_set_loadable(app, None, &set)?; + let (unknown, unknown_count, first) = unknown_commands(&set); let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); - Ok(json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions })) + let mut answer = json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions }); + if let Some((action, step, command)) = first { + app.status(crate::i18n::msg_n( + unknown_count as u64, + "Loaded “{name}”: {n} step has an unknown command and was kept: “{action}”, step {step}, `{command}`", + "Loaded “{name}”: {n} steps have unknown commands and were kept; the first: “{action}”, step {step}, `{command}`", + &[ + ("name", &super::shown_text(&name)), + ("action", &super::shown_text(&action)), + ("step", &step), + ("command", &super::shown_text(&command)), + ], + )); + answer["unknownCommands"] = json!(unknown); + answer["unknownCount"] = json!(unknown_count); + } + Ok(answer) +} + +/// The steps of `set` whose command is not known now ([`super::KnownCommands`]), excluded steps +/// included: the first [`MAX_SKIPPED`] as `[{action, actionIndex, step, stepIndex, batchStep?, +/// command}]` (`step` and `batchStep` count from 1, the indexes from 0), how many there are, and the +/// first one's action name, place ([`super::step_place`]) and command. +fn unknown_commands(set: &ActionSet) -> (Vec, usize, Option<(String, String, String)>) { + let known = super::KnownCommands::now(); + let (mut listed, mut count, mut first) = (vec![], 0usize, None); + for (a, action) in set.actions.iter().enumerate() { + for (step, in_batch, command) in action.unknown_commands(&known, false) { + count += 1; + first.get_or_insert_with(|| (action.name.clone(), super::step_place(step, in_batch), command.to_string())); + if listed.len() < MAX_SKIPPED { + let mut entry = json!({ "action": action.name, "actionIndex": a, "step": step + 1, "stepIndex": step, "command": command }); + if let Some(j) = in_batch { + entry["batchStep"] = json!(j + 1); + } + listed.push(entry); + } + } + } + (listed, count, first) } diff --git a/docs/mcp.md b/docs/mcp.md index 66c4cc0cff..3711621ddc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3176,7 +3176,13 @@ already walks Image Trace. Short agent entry points: written, and the command returns the same error as a load of such a file. `ui.actionSet.load {path? | data?}` reads the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns `{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the - other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON: + other sets and the current set stay as they are. A step whose command is not a built-in command, a command of an + installed plug-in, a View menu step or a stop, and such a command of a `command.batch` step, loads as it is, excluded + or not, and `ui.actionSet.save` writes it back. The answer then also has `unknownCommands: [{action, actionIndex, step, + stepIndex, batchStep?, command}]`, the first 1,000 such steps (`step` and `batchStep` count from 1, `actionIndex` and + `stepIndex` from 0, and `batchStep` is the command's place in a `command.batch` step), and `unknownCount`, how many + there are; the status bar names the first and the count, with control characters replaced and long names cut at 80 + characters. These are VectorCraft's own rules. An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 6, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with `{message, allowContinue}`. A Select Object step is the command `select.byNote` with `{note, caseSensitive, @@ -3255,8 +3261,12 @@ already walks Image Trace. Short agent entry points: made active, or that document was closed), the playback ends with a status and the rest do not play; a step that makes another document active itself does not end it. A name that no action has, or that more than one action in the sets looked in has, or whose every step is excluded, is an error, and - then nothing plays. So is an action played while another is still playing at Step by Step or Pause For. A - `command.batch` step, which an action played or a panel operation run as one batch records, plays as its own steps, + then nothing plays. So is an action played while another is still playing at Step by Step or Pause For. So is an + action with an included step whose command is not a built-in command, a command of an installed plug-in, a View menu + step or a stop at the time it plays, at every speed: the error names the first such step, as in step 2 of “Print” + runs the unknown command `example.finish`, and a command of a `command.batch` step by its place in the batch, as in + step 2.3. An excluded step is not checked. + A `command.batch` step, which an action played or a panel operation run as one batch records, plays as its own steps, here and in Batch…. An action with an included stop plays up to the stop at any speed and then waits, with its message box open, until `ui.action.continue` or `ui.action.stop`. At Accelerated the steps between two stops are one undoable batch each. The result is then `{results, pending, atStop?}`, with `atStop: true` when the action already diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..55b89a0cc5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -751,6 +751,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name. - Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html); `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number. + - Actions panel menu: Load Actions… keeps a step whose command this build or its installed plug-ins do not have, and reports each one when the set loads: the `ui.actionSet.load` answer lists the action, step and command id (`unknownCommands`, `unknownCount`), and the status bar names the first and the count. An action with such a step included does not play, and the error names the step and its id before any step runs; an excluded one does not stop it. These are VectorCraft's own rules. Tests `panels::actions::tests::{load_actions_reports_steps_with_unknown_commands, load_actions_bounds_what_it_reports_of_unknown_commands, an_action_with_an_unknown_command_does_not_play}`. - Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. From b097c2a957b4c53f8d37b56f7e78d66c495d2aa4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:30:17 -0800 Subject: [PATCH 4/9] .ai export: the masked pieces of a warped image no longer hide one another A raster mesh warp with Preserve Shape Using Transparency cuts an image into pieces with opacity masks and puts them in a knockout group, each piece knocking out its triangle. The reader gives the pieces no such mark, so each knocked out all that its image paints without its mask, and the file opened with most of the image hidden. The group of such pieces is now written without knockout. An image whose pixels are partly transparent draws its alpha twice only where neighboring pieces overlap. --- crates/eps/src/ai_editing/tests.rs | 27 ++++++++++++++++++++++++++- crates/eps/src/ai_editing/writer.rs | 19 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index b433e6e1f0..04ec867063 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7398,16 +7398,19 @@ fn the_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { } /// The pieces of a warped image that Preserve Shape Using Transparency cuts with opacity masks that -/// clip are written as the parts their masks show, and the file reads back drawing as it did. +/// clip are written as the parts their masks show, in a group without knockout, and the file reads +/// back drawing as it did. #[test] fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { // GUARDS: each masked piece of a warped image was written with all of the image's samples. + // GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it. let doc = photo_doc(|d| { let photo = speckled_photo(d); let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true) }); + assert_eq!(doc.layers[0].children().unwrap()[0].knockout, vectorcraft_doc::Knockout::On); let sizes = sample_sizes(&doc); assert_eq!(sizes.len(), 32, "{sizes:?}"); assert_eq!(sizes[0], SPECKLED_WHOLE, "the first piece holds the whole image"); @@ -7422,6 +7425,28 @@ fn masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show() { assert!((st.bad_pixels as f64) < 0.005 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); } +/// The masked pieces of a warped image whose pixels are half transparent read back drawing as they +/// did, except where neighboring pieces overlap: there the image's alpha draws twice. +#[test] +fn masked_pieces_of_a_translucent_warped_image_read_back_but_for_their_overlaps() { + // GUARDS: the pieces' knockout group read back with each piece hiding the pieces below it + // (8,942 of 20,000 pixels differed). + let doc = photo_doc(|d| { + let px = image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba([(x * 4) as u8, (y * 4) as u8, ((x * 7 + y * 13) % 256) as u8, 128])); + let photo = image_node(d, ImageBlob::png(png_of(px.into())), (64, 64), Affine::new([2.0, 0.0, 0.0, 2.0, 20.0, 10.0]), None); + let NodeKind::Image(im) = &photo.kind else { panic!("not an image") }; + let bend = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 12.0 * ((p.x - 20.0) / 128.0 * std::f64::consts::PI).sin()); + vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, true) + }); + let back = read_back(&doc); + let st = vectorcraft_testkit::raster::perceptual_diff( + &vectorcraft_testkit::raster::render_artboard(&doc), + &vectorcraft_testkit::raster::render_artboard(&back), + 8.0, + ); + assert!((st.bad_pixels as f64) < 0.05 * st.pixels as f64, "{} of {} pixels differ", st.bad_pixels, st.pixels); +} + /// An image with a live Warp keeps its pixels whole once, and its drawn look's pieces are each the /// part of them they show: it reads back as the image with its Warp live. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index de6c40b7b5..570c65daa2 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -648,7 +648,14 @@ impl<'a> Writer<'a> { } self.effects(n); self.state(n.locked, !n.visible); - let xy = Xy::of(n); + let mut xy = Xy::of(n); + if masked_warp_pieces(n) { + // The reader gives the pieces no mark, and a knockout group's member then knocks out + // what it paints without its opacity mask: each piece would hide the pieces below it. + // Without knockout, a partly transparent image draws its alpha twice only where the + // pieces overlap. + xy.knockout = Knockout::Off; + } let start = self.body.len(); match &n.kind { NodeKind::Layer { .. } => {} @@ -1659,6 +1666,16 @@ fn clip_path(n: &Node) -> Option<(PathData, FillRule)> { path.subpaths.iter().any(|sp| !sp.anchors.is_empty()).then_some((path, rule)) } +/// Is `n` a knockout group of the pieces of a mesh-warped image cut by opacity masks +/// ([`vectorcraft_doc::live::mesh_warp_image`] with Preserve Shape Using Transparency)? Each piece +/// knocks out its triangle ([`Node::knockout_outline`]); read from the data, a piece knocks out all +/// that its image paints instead. +fn masked_warp_pieces(n: &Node) -> bool { + n.knockout == Knockout::On + && matches!(&n.kind, NodeKind::Group { children, clip: false } + if !children.is_empty() && children.iter().all(|c| c.extras().warp_piece && c.extras().mask.is_some())) +} + /// `text`'s lines on `%_` lines, as a part of the data that readers of the printed form skip (an /// opacity mask's art, a pattern's tile) has them. An image's samples stay as they are after an /// `XI` without `%_`, and `%_%%EndData` ends them, as in the files users sent. Samples hidden From 7b475b46b0b47663a9828365f1c16f6f06498dda Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:05:00 -0800 Subject: [PATCH 5/9] .ai import: Calligraphic paths with pen samples keep their drawn look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A path's own lines can be followed by samples of the pen (`(…) Xt n XD`). The reader took them as the end of the object, so the style marker after them was not matched: the path came in as its drawn look plus a second, unpainted copy of the path, and no note said the look was kept. The samples now stay with the path. They aren't read, so a path that has them keeps its drawn look alone, with the note, when a setting of its Calligraphic brush varies with the pen (Pressure, Tilt, Bearing or Rotation, with a variation above 0). With any other style, an Art brush or a Calligraphic brush that doesn't follow the pen included, it takes the style as it would without them. A Calligraphic brush's filter with an entry the reader doesn't know, or with a variable width, keeps the drawn look too, as an Art brush's filter does. Docs: docs/file-format-parity.md, docs/gaps.md. --- crates/eps/src/import/ai/brush.rs | 3 +- crates/eps/src/import/ai/read.rs | 5 ++ crates/eps/src/import/ai/read/ops.rs | 37 ++++++++++++-- crates/eps/src/import/ai/read/types.rs | 3 +- crates/eps/src/import/ai/style.rs | 31 ++++++++++-- crates/eps/src/import/ai/tests.rs | 68 +++++++++++++++++++++++++- docs/file-format-parity.md | 2 +- docs/gaps.md | 5 ++ 8 files changed, 141 insertions(+), 13 deletions(-) diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 5a5db47114..7b4023afdd 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -18,7 +18,8 @@ //! 1 pt draws it at its own size), the brush by name (`/GObjRef : (name) (plug-in) //! /PluginObject`), and the brush's settings for this path as entries: `Diameter`, `Roundness`, //! `Angle`, their `…Variation` either side and `…DependsOn` codes, and `AngleRelativeTo` (0 the -//! page). +//! page). Samples of the pen may follow a path's own lines on `%_` lines (`(hex) Xt n XD`). They +//! aren't read, so such a path keeps its drawn look when a setting varies with the pen. //! - A path with an Art brush is a stroke in its art style too: the filter //! `(Adobe ArtOnPath Brush Tool)`, its stroke's paint and weight (the weight scales the art across //! the path), the brush by name, and its settings for this path as entries: `Scaling`, diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index b77bc32199..d460e317f5 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -598,6 +598,10 @@ struct Reader<'a> { compound_groups: HashSet, /// Groups of a text object and its look (see [`Self::style_marker`]), which aren't a look. text_looks: HashSet, + /// Objects followed by samples of the pen (`(…) Xt n XD`), which aren't read: such an object + /// whose style has a Calligraphic brush that varies with the pen keeps its drawn look (see + /// [`Self::style_marker`]). + pen_samples: HashSet, /// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line. mesh: Option<(super::mesh::MeshReader, bool)>, /// The art styles (`%AI9_BeginArtStyles`), by name. @@ -790,6 +794,7 @@ impl<'a> Reader<'a> { ruler_guides: HashMap::new(), compound_groups: HashSet::new(), text_looks: HashSet::new(), + pen_samples: HashSet::new(), group_ended: false, mesh_ended: false, layer_state: None, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 50a356e524..1bc5d036b5 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -89,6 +89,18 @@ impl<'a> Reader<'a> { } } + /// `(…) Xt n XD`: samples of the pen along the object before it. They aren't read, so the object + /// keeps its drawn look when its style has a Calligraphic brush with a setting that varies with + /// the pen ([`Self::style_marker`]). + fn pen_samples_op(&mut self) { + if !self.after_object || self.pen_samples.len() >= MAX_NODES { + return; + } + if let Some(n) = self.frames.last().and_then(|f| f.children.last()) { + self.pen_samples.insert(n.id); + } + } + /// `/identifier (string) XT` (version 7 specification §7): a tag on the object before it. The /// `AdobeURL` tag sets the object's URL, and every other tag is added to the object's data in /// order, up to [`MAX_TAGS`]. A tag with no object before it, with an empty identifier, or with @@ -418,6 +430,7 @@ impl<'a> Reader<'a> { self.end_envelope(); } } + "XD" => self.pen_samples_op(), _ if IGNORED.contains(&w) => {} // Before the art, the setup runs procedures of the printing format. _ if self.frames.is_empty() => {} @@ -559,9 +572,9 @@ impl<'a> Reader<'a> { return; } self.image_tail = false; - // An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's - // follow the `XH` that ends its data. - if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") { + // An object's tags (`XT`, and a graph's `Go`) and samples of the pen (`Xt`, `XD`) follow it + // one after another, and an image's tags follow the `XH` that ends its data. + if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go" | "Xt" | "XD") { return; } self.after_object = false; @@ -688,6 +701,8 @@ impl<'a> Reader<'a> { /// drawn look before it (all on `%_` lines inside another such object). Other markers end what /// follows an object. fn style_marker(&mut self, vals: &[V], hidden: bool) { + use crate::import::ai::brush::PathBrush; + use crate::import::ai::style::{ArtStyle, StyleItem}; let code = Self::nums(vals).last().copied().unwrap_or(0.0); let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); @@ -754,8 +769,20 @@ impl<'a> Reader<'a> { } else { look }; - // A style read here: the object, painted as it says. - let appearance = self.styles.get(&style).and_then(|st| { + // A style read here: the object, painted as it says. An object with samples of the pen + // keeps its drawn look when its style has a Calligraphic brush with a setting that varies + // with the pen: the samples aren't read, and the brush draws such a stroke at the pen's + // middle values. + let sampled = self.pen_samples.contains(&object.id); + let follows_pen = |st: &ArtStyle| { + st.items.iter().any(|i| match i { + StyleItem::Stroke { brush: Some((_, PathBrush::Calligraphic(c))), .. } => { + c.modes().iter().zip(c.variation).any(|(m, v)| m.follows_pen() && v != 0.0) + } + _ => false, + }) + }; + let appearance = self.styles.get(&style).filter(|st| !(sampled && follows_pen(st))).and_then(|st| { styled( object, look, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 83a838a6cb..b6e908c2e1 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -386,9 +386,8 @@ pub(super) const IGNORED: &[&str] = &[ // Path resolution (`Ar`, §12): how finely curves print. "Ar", "Xs", + // The samples of the pen that `XD` notes on the object before them (`Reader::pen_samples`). "Xt", - // Five indexed curves of samples along a brushed path (`(…) Xt n XD`), on its hidden source path. - "XD", "Xi", "XI", "LB2", diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index d27a297925..313132ed5a 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1445,13 +1445,38 @@ fn offset_stroke(f: &Filter) -> Result, ()> { stroke_item(f, if inside { StrokeAlign::Inside } else { StrokeAlign::Outside }) } +/// The entries a Calligraphic brush's filter may hold, with the settings [`calligraphic_stroke`] +/// reads. Any other entry keeps the drawn look. +const CALLIGRAPHIC_ENTRIES: [&str; 14] = [ + "StrokeStyle", + "DisplayString", + "Brush", + "Diameter", + "DiameterVariation", + "DiameterDependsOn", + "Roundness", + "RoundnessVariation", + "RoundnessDependsOn", + "Angle", + "AngleVariation", + "AngleDependsOn", + "AngleRelativeTo", + "Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files +]; + /// A Calligraphic brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a -/// colour, names no Calligraphic brush, or its settings aren't all there as numbers. An angle -/// relative to the path isn't read. +/// color, names no Calligraphic brush, holds an entry or a width that isn't read, or its settings +/// aren't all there as numbers. An angle relative to the path isn't read. fn calligraphic_stroke(f: &Filter) -> Option { let real = |key: &str| entry(f, key, Kind::Real).ok().flatten(); let code = |key: &str| entry(f, key, Kind::Int).ok().flatten(); - if !f.paints.0 || f.gradient || f.pattern.is_some() || f.repeated { + if !f.paints.0 + || f.gradient + || f.pattern.is_some() + || f.repeated + || !f.arrays.is_empty() + || !f.kinds.keys().all(|k| CALLIGRAPHIC_ENTRIES.contains(&k.as_str())) + { return None; } if entry(f, "AngleRelativeTo", Kind::Int).ok()?.unwrap_or(0.0) != 0.0 { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index daf99047f3..1b9cc54ea5 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -10228,20 +10228,86 @@ fn a_path_with_a_calligraphic_brush_comes_in_brushed() { let pieces = vectorcraft_brush::stroke_pieces(&r.doc, &lib[0], &a[0].path_data().unwrap().to_bezpath(), st); let height = pieces.iter().filter_map(|n| n.geometric_bounds()).map(|b| b.height()).fold(0.0, f64::max); assert!(height > 40.0 && height < 46.0, "the line's rise and about half the nib's 3 pt: {height}"); + // A filter with the version entry an effect's filter may carry still comes in brushed. + let versioned = style.replace("(Brush) ,\n", "(Brush) ,\n15 /Int (Adobe Effect Expand Before Version) ,\n"); + let a = art(&read_ok(&format!("{def}{versioned}{}", layer(&brushed_path()))).doc); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{a:#?}"); +} + +/// Samples of the pen along a path (`(…) Xt n XD`), as they follow its own lines. +const PEN_SAMPLES: &str = "%_(00FE64FE) Xt 0 XD\n%_(00006400) Xt 1 XD\n%_(00986498) Xt 2 XD\n%_(009E649E) Xt 3 XD\n%_(007F647F) Xt 4 XD\n"; + +/// A path with a Calligraphic brush that varies with the pen, followed by samples of the pen, comes +/// in as its drawn look alone, with the note that says so: the samples aren't read, and the brush +/// would draw it at the pen's middle values. Its own path doesn't come in beside the look. A brush +/// that doesn't follow the pen draws such a path the same without the samples, so it comes in +/// brushed. +#[test] +fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() { + // GUARDS: the path came in beside its look, unpainted, and the note was missing. + let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); + let style = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [2, 5, 6], 0); + let path = brushed_path().replace("%_n\n", &format!("%_n\n{PEN_SAMPLES}")); + let r = read_ok(&format!("{def}{style}{}", layer(&path))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}"); + assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); + // The samples belong to the path before them: a path after them is read as it was. + let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc); + assert_eq!(a.len(), 2, "{a:#?}"); + assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}"); + // Samples with no object before them in the layer mark nothing: the path after them is read. + let first = format!("{PEN_SAMPLES}{}", brushed_path()); + let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path, brushed: {a:#?}"); + // A brush whose modes are Fixed and Random, one whose pen modes vary by 0, and an Art brush + // don't follow the pen: the path comes in brushed. + let fixed_def = plugin_object(CALLIGRAPHIC, "Six D", &SIX_D.replace(" 2 5 6 0", " 0 1 0 0")); + let fixed = brushed_style("Six D", 0.5, SIX_D_SETTINGS, [0, 1, 0], 0); + let even_def = plugin_object(CALLIGRAPHIC, "Six D", "1 6 6 40 40 30 30 2 5 6 0"); + let even = brushed_style("Six D", 0.5, [(6.0, 0.0), (40.0, 0.0), (30.0, 0.0)], [2, 5, 6], 0); + for (why, def, style) in [("Fixed and Random", fixed_def, fixed), ("no variation", even_def, even)] { + let r = read_ok(&format!("{def}{style}{}", layer(&path))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{why}: {a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, brushed: {a:#?}"); + assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{why}: {:?}", r.warnings); + } + let ribbon = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON)); + let a = art(&read_ok(&format!("{ribbon}{}{}", art_style("Ribbon", 2.0, &STRETCHED, ""), layer(&path))).doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{a:#?}"); + // A style with no brush doesn't draw the samples: the path takes it as it would without them. + let square = styled_square("Blue").replace("f\n1 (Blue) XW", &format!("f\n{PEN_SAMPLES}1 (Blue) XW")); + let r = read_ok(&format!("{}{}", styles_and_list(&[fill_style("Blue", "0 0 0 0 0 0 1 Xa\n")], &["Blue"]), layer(&square))); + let a = art(&r.doc); + assert_eq!(a.len(), 1, "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the path with its style: {a:#?}"); + assert_eq!(a[0].extras().graphic_style, Some(1), "linked to its style"); } /// A brushed path keeps its drawn look when its stroke can't be read as the brush: the object's /// own settings differ from the brush's (Options of Selected Object), its angle follows the path, -/// or the file defines no brush of its name. +/// its filter holds an entry or a variable width that isn't read, or the file defines no brush of +/// its name. #[test] fn a_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() { let def = plugin_object(CALLIGRAPHIC, "Six D", SIX_D); let mut own = SIX_D_SETTINGS; own[0] = (9.0, 2.0); + let plain = brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0); + let with = |more: &str| plain.replace("(Brush) ,\n", &format!("(Brush) ,\n{more}")); + let widths = "/Dictionary : /NotRecorded ,\n/Array :\n0 /Real ,\n1 /Real ,\n; (BSVariableWidthTValuesArray) ,\n\ + /Array :\n1 /Real ,\n0.1 /Real ,\n; (BSCenteredWidthsArray) ,\n; (kAIBeautifulStrokesParamsDictKey) ,\n"; let cases = [ ("its own settings", def.clone(), brushed_style("Six D", 1.0, own, [2, 5, 6], 0)), ("its own modes", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 4], 0)), ("an angle on the path", def.clone(), brushed_style("Six D", 1.0, SIX_D_SETTINGS, [2, 5, 6], 1)), + ("an entry that isn't read", def.clone(), with("1 /Real (Unread) ,\n")), + ("a variable width", def.clone(), with(widths)), ("no such brush", def, brushed_style("Other", 1.0, SIX_D_SETTINGS, [2, 5, 6], 0)), ]; for (why, def, style) in cases { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d6816744fb..ca7c2a3e81 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -264,7 +264,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, placed PDF and `.ai` pages as placed documents of the [page and Crop to box](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/adobe-pdf-placement-options.html) their dictionary names (`AI11 PlacedPDFPageIndex`, `AI11 PlacedPDFCropTo`; a value VectorCraft doesn't read, or a binding to an image variable, leaves a linked image), guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile on a Stroke Offset stroke or written as each side's widths) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing), Pucker & Bloat at 0 or Bas Relief (read from its settings block: [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646)) comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way when nothing in the object is stroked: up to 180° either way (a limit VectorCraft sets) on a path or compound path whose segments are all straight, and up to 90° either way (a limit VectorCraft sets) on another path, compound path or group with no guide in it. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a variable-width stroke with one centered width array, dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own, an entry that isn't read, a variable width or an angle that follows the path, a path followed by samples of the pen (`(…) Xt n XD`) whose Calligraphic brush varies with the pen, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. When the file opens from its layers, the spot colors in the art of each brush that comes in become swatches, and that art's colors count toward the document's color mode, as a pattern's do. The swatches take at most 20,000 color names in all across the brush art, and each brush-art section keeps at most 20,000 names. Each name keeps its first definition, with the color operators' names before the gradient stops' names. A spot color past those limits makes no swatch, and its color still counts toward the color mode. A spot color that the page's art, a pattern or a symbol also defines keeps the value defined there. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its detail, smoothness and light direction from its settings block, or the effect's defaults when the style holds no settings block; a settings block that isn't read keeps the drawn look) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; type on a path keeps its frame's Type on a Path effect, Align to Path and Flip (a frame whose options set key 6 to true keeps their defaults), and the first frame's Spacing is fitted from the file's layout, not read from the dialog's value; the distance from the path under Align to Path is measured for each character at its size and vertical scale (in vertical type, its horizontal scale), from the top of its font's em box (Ascender), half the height of the top of its font's H (Center) or the bottom of its font's bounding box (Descender); the fit under Gravity does not reproduce the file's layout, and its glyphs can lie up to 7.4 pt from the positions the file keeps; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. A file that comes in as its page or PDF part because its editing data has no layers (a symbol library), or because its layers can't be read or used (any of the cases below), gets the symbols its editing data defines before its first layer, in the symbol list's order, while its page art stays as the page draws it. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. diff --git a/docs/gaps.md b/docs/gaps.md index 201823b983..a3f2482950 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -217,6 +217,11 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. +- **Done (2026-11-14):** Calligraphic paths whose brush filter holds an entry that isn't read or a variable width + keep their drawn look. So do paths followed by samples of the pen (`(…) Xt n XD`) whose Calligraphic brush varies + with the pen; such a path no longer also comes in beside its look, unpainted. Synthetic importer regressions; + scores unchanged. + - **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art From 94db0b1ebd4737d1744060e0dd127b6af9c46174 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:20:35 -0800 Subject: [PATCH 6/9] .ai export: a masked image copy whose mask is left out keeps all of its pixels A copy of an image written whole already was cut to the painted bounds of its opacity mask even when the mask was then left out, nested too deep or past the object limit, so the file read back with a cropped image and no mask. The copy is now cut to its mask only when the mask is written, and otherwise written whole. The progress-doc line now gives the measured total for the warped test image's pieces: about 7 times the image's samples, not twice. --- crates/eps/src/ai_editing/tests.rs | 66 ++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 33 ++++++++--- crates/eps/src/ai_editing/writer/images.rs | 35 ++++++++++-- docs/target-app-parity.md | 2 +- 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 04ec867063..a120ac0c82 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7649,6 +7649,72 @@ fn a_masked_image_keeps_the_pixels_its_mask_effects_show() { assert!(sizes[1] >= 35 * 35 * 3, "{sizes:?}"); } +/// The speckled image with an opacity mask that clips it to six small white squares in its corner. +fn photo_masked_to_its_corner(doc: &mut Document) -> Node { + let mut masked = speckled_photo(doc); + let squares = (0..6).map(|i| square(doc, Rect::new(20.0 + f64::from(i) * 5.0, 10.0, 23.0 + f64::from(i) * 5.0, 20.0), Color::WHITE)).collect(); + let art = group(doc, squares, false); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true })); + masked +} + +/// A copy of an image written whole already whose opacity mask is left out for the object limit is +/// written whole: without the mask, it shows all of its pixels. +#[test] +fn a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels() { + // GUARDS: the copy was cut to its mask, and the mask then left out, so it read back cropped. + let doc = photo_doc(|d| { + let photo = speckled_photo(d); + let masked = photo_masked_to_its_corner(d); + group(d, vec![photo, masked], false) + }); + let (mut left_out, mut kept) = (0, 0); + for (max, mask, _, shown) in samples_within_each_limit(&doc) { + if shown.len() < 2 { + continue; + } + if mask { + assert!(shown[1] < SPECKLED_WHOLE, "room for {max}: {shown:?}"); + kept += 1; + } else { + assert_eq!(shown, [SPECKLED_WHOLE, SPECKLED_WHOLE], "room for {max}"); + left_out += 1; + } + } + assert!(left_out > 0 && kept > 0, "{left_out} limits left the mask out, {kept} kept it"); +} + +/// A copy of an image written whole already whose opacity mask is nested too deep to be written is +/// written whole. +#[test] +fn a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels() { + // GUARDS: the copy was cut to its mask, and the mask then left out for its depth. + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let photo = speckled_photo(&mut doc); + doc.insert(Some(layer), usize::MAX, photo).unwrap(); + // Isolated instances are written as their art, each a level deeper: the masked copy is at the limit. + let art = photo_masked_to_its_corner(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(art) }); + for i in 1..writer::MAX_NEST { + let mut inner = instance(&mut doc, &format!("S{}", i - 1), Affine::IDENTITY); + inner.isolate = true; + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(inner) }); + } + let mut outer = instance(&mut doc, &format!("S{}", writer::MAX_NEST - 1), Affine::IDENTITY); + outer.isolate = true; + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::DEEP_MASKS), "{:?}", out.warnings); + // The symbols' definitions hold copies too: each copy written with its mask is the part the + // mask shows, and each copy whose mask is left out is whole. + let sizes = sample_sizes(&doc); + let whole = sizes.iter().filter(|s| **s == SPECKLED_WHOLE).count(); + let masks = String::from_utf8_lossy(&out.program).matches("(AI9_Mask)").count(); + assert_eq!(sizes.len() - whole, masks, "{sizes:?}"); + assert!(whole >= 2, "the first copy and a copy whose mask is left out: {sizes:?}"); +} + /// A linked image, an image with an opacity mask, and an image with a Warp and another effect are /// written as their drawn art, with no art style. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 570c65daa2..0373624b64 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -282,6 +282,8 @@ pub(super) struct Writer<'a> { /// The outlines of the clipping groups the art being written is in, in the document (empty in /// a definition, an opacity mask's art and a brush's art, which are written apart from them). shown: Vec, + /// The copy of an image that the last [`Self::image`] wrote as a window, when it did. + windowed: Option, } impl<'a> Writer<'a> { @@ -350,6 +352,7 @@ impl<'a> Writer<'a> { last_samples: None, whole_images: HashMap::new(), shown: vec![], + windowed: None, } } @@ -657,6 +660,8 @@ impl<'a> Writer<'a> { xy.knockout = Knockout::Off; } let start = self.body.len(); + // An image's copy written as the part that its opacity mask, among other regions, shows. + let mut mask_window = None; match &n.kind { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), @@ -690,14 +695,16 @@ impl<'a> Writer<'a> { .extras() .mask .as_ref() - .filter(|m| m.clip && !m.invert && !m.disabled) + .filter(|m| m.clip && !m.invert && !m.disabled && self.nest < MAX_NEST) .and_then(|m| vectorcraft_render::painted_bounds(self.doc, &m.art)); if let Some(b) = masked { self.shown.push(b.to_path(0.1)); } + self.windowed = None; self.image(&self.doc.shown_image(n, im), xy); if masked.is_some() { self.shown.pop(); + mask_window = self.windowed.take(); } } NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), @@ -763,10 +770,14 @@ impl<'a> Writer<'a> { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); self.block_end = usize::MAX; - self.dictionary(n); + let mask_written = self.dictionary(n); if let Some(b) = block { self.body.push_str(&b); } + // Without its opacity mask, the image shows whole. + if let Some(w) = mask_window.filter(|_| !mask_written) { + self.write_whole(w); + } } // An envelope's plugin `XP` follows its distorted art, after the art's state block. if deform { @@ -796,24 +807,27 @@ impl<'a> Writer<'a> { /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an /// empty `AIArtName`), has an opacity mask (`AI9_Mask`, see [`Self::mask`]), or is a symbol's - /// art (its symbol's entries first, see [`Self::define_symbol`]). - fn dictionary(&mut self, n: &Node) { + /// art (its symbol's entries first, see [`Self::define_symbol`]). False when `n` has an opacity + /// mask that is left out: nested too deep, or its art doesn't fit. + fn dictionary(&mut self, n: &Node) -> bool { let name = n.name.as_deref().filter(|s| !s.is_empty()); let placed = std::mem::take(&mut self.placed); let typed = std::mem::take(&mut self.typed); let bound = self.bound_variables(n, placed, typed); let mut mask = n.extras().mask.as_deref(); + let mut mask_written = true; // Masks nested too deep are left out (an empty mask would hide the object). if mask.is_some() && self.nest >= MAX_NEST { self.warn(DEEP_MASKS); mask = None; + mask_written = false; } let entries = match &self.root_entries { Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e), _ => None, }; if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() { - return; + return mask_written; } let start = self.body.len(); let has_id = name.is_some() || !bound.is_empty(); @@ -836,12 +850,15 @@ impl<'a> Writer<'a> { // A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too. if let Some(m) = mask && !self.mask(m) - && !has_id - && !has_entries { - return self.body.truncate(start); + if !has_id && !has_entries { + self.body.truncate(start); + return false; + } + mask_written = false; } self.body.push_str("%_;\n%_\n"); + mask_written } /// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id. diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index d69b1d7e09..0ebfee5228 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -30,6 +30,18 @@ pub(super) const SAMPLES_LINE: &str = "XI\u{1}"; /// An image's pixels as the data holds them, and whether it had 16 bits a channel ([`Samples::of`]). pub(super) type Read = Arc<(Samples, bool)>; +/// A copy of an image written as the part of it that its regions show ([`Writer::image`]). +pub(super) struct Windowed { + /// Where its raster is in the body. + at: std::ops::Range, + /// The index of its samples in [`Writer::payloads`]. + payload: usize, + /// The image's pixels. + samples: Read, + /// What places the image's whole pixel grid in the document. + xf: Affine, +} + /// An image's pixels as the data holds them. pub(super) struct Samples { /// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`. @@ -166,11 +178,14 @@ impl Writer<'_> { ai_ops::image_window(&clips, im.xf * fit, (samples.width, samples.height)) }) .flatten(); - match window { - Some(window) => { - self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1])))) + match (window, &read) { + (Some(window), Some(read)) => { + let at = self.body.len(); + self.raster(&samples.window(window), im.xf * fit * Affine::translate((f64::from(window[0]), f64::from(window[1])))); + self.windowed = + Some(Windowed { at: at..self.body.len(), payload: self.payloads.len() - 1, samples: read.clone(), xf: im.xf * fit }); } - None => { + _ => { if let Some(d) = data { // `raster` adds the samples as the next payload. A copy written whole on // `%_` lines after an earlier whole copy keeps the earlier one's entry. @@ -290,6 +305,18 @@ impl Writer<'_> { read } + /// The copy `w`, written as a window, written whole in its place, with fresh samples; its + /// window's samples are emptied. + pub(super) fn write_whole(&mut self, w: Windowed) { + let tail = self.body.split_off(w.at.end); + self.body.truncate(w.at.start); + if let Some(p) = self.payloads.get_mut(w.payload) { + *p = Vec::new(); + } + self.raster(&w.samples.0, w.xf); + self.body.push_str(&tail); + } + /// Keep `bytes` to go in the program where the mark this returns is. fn payload(&mut self, bytes: Vec) -> String { self.payloads.push(bytes); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d2b693c1ec..2e1acd44bd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -617,7 +617,7 @@ since 2026-10-01. - **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged. - **`.ai` files keep an image's Warp live:** an embedded image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp: its bent pieces are its drawn look, and the image itself follows on `%_` lines, as for other styled objects. Opening an `.ai` file reads such an image as the image with its Warp live, its pixels, placement, name and opacity kept, when the live warp draws as the file's drawn look and the import has a comparison left; otherwise the drawn look stays, with the drawn-look note. The Warp effect applies to bitmap images ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)), and native files keep live effects ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/supported-file-formats-in-illustrator.html)). A linked image with a Warp is saved as its drawn art, as before, and its live Warp is not saved. Images with an opacity mask, and images with a Warp and another effect or with another geometry effect, are saved and opened as their drawn art. Existing `.ai` files with an embedded image on `%_` lines open differently: before, that image also came in beside its drawn look. Which images keep the Warp and the comparison are VectorCraft's own rules (eps `ai_editing::tests::an_image_with_a_warp_reads_back_with_its_warp_live`, `an_image_whose_warp_draws_otherwise_than_its_look_keeps_its_look`, `an_image_on_hidden_lines_does_not_come_in_beside_its_look`, `other_warped_images_are_written_as_their_drawn_art`, `import::ai::read::ops::tests::an_image_takes_a_style_of_one_warp_alone`). Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. -- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now add about twice the image's pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged. +- **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now hold about 7 times the image's pixels in all for a 64 × 64 image. A copy whose opacity mask is left out, nested too deep or past the object limit, keeps all of its pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels`, `a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged. - **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. From 6bd675528390e04386d8c0e40ef7f37a61939e50 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:02:06 -0800 Subject: [PATCH 7/9] Effects: a clipping path's geometry effects shape the clip on the canvas A clipping path with Roughen, Transform or another effect that reshapes a path drew its own fill and stroke reshaped, but the canvas and raster export still clipped the art by the plain path. SVG and PDF export bake the effect into the path before writing the clip path, so the screen and a saved SVG showed different art. effects::clip_outline now applies the live geometry effects in the clipping path before taking its region: the path's or compound path's own effects, the effects of its fills and strokes (the region is then the union of the paths they give, as an export bakes them), the effects of a compound path's paths, of a group's visible members and of a nested clip group's clipping path, shown or hidden, and a group's own effects as an export evaluates them. Raster effects such as Gaussian Blur leave the region as it is. The renderer, the engine commands that measure a clip region (flatten, arrowheads, cutting) and the writers use it; the writers receive the baked document, so their output for a path with effects does not change. The renderer measures a clip group by the region its clipping path's effects give it, so a view or tile that shows only the moved part of the clip still draws the clipped art, and it drops cached clip regions that hold a plug-in effect when plug-ins change. Two rules change in every export as well as on the canvas: - When the clip group, or a group around it, has a geometry effect of its own, the clipping path's effects apply first and its gradients move with them. #7114 left the clipping path's effects live under the group's effect, so they applied after it. - Type used as a clipping path keeps its own effects when a group's effect outlines it. #2073 and #2290 outlined it without them, to match the plain clip the canvas drew. --- crates/effects/src/bake.rs | 14 +- crates/effects/src/bake/tests.rs | 6 +- crates/effects/src/clip.rs | 113 ++++++++- crates/effects/src/group.rs | 14 +- crates/effects/src/group/tests.rs | 8 +- crates/effects/src/reshape.rs | 18 +- crates/effects/src/tests.rs | 1 + crates/effects/src/tests/clipfx.rs | 315 ++++++++++++++++++++++++++ crates/render/src/fx/bounds.rs | 11 + crates/render/src/renderer/nodes.rs | 5 +- crates/render/src/tests/clip.rs | 36 +++ crates/render/src/tests/container.rs | 6 +- crates/render/src/tests/memory.rs | 10 + crates/svg/tests/it/effects_export.rs | 31 +++ docs/target-app-parity.md | 1 + 15 files changed, 563 insertions(+), 26 deletions(-) create mode 100644 crates/effects/src/tests/clipfx.rs diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 7e42147443..fe1f355c9a 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -26,13 +26,13 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, - has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, }; -fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { +pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { AppearanceItem::Fill(f) => &f.effects, AppearanceItem::Stroke(s) => &s.effects, @@ -73,7 +73,7 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> { /// `path` with the geometry effects of `effects` applied, and how gradients follow them /// ([`crate::Carry`]). -fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { +pub(crate) fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) { if !has_geometry(effects) || path.is_empty() { return (path.clone(), crate::Carry::default()); } @@ -85,7 +85,7 @@ fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext /// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient /// moved along with them and the Transform effects the geometry pass left out hidden /// ([`crate::Carry::hide_skipped`]). -fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { +pub(crate) fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) { let (ig, carry) = apply(item_effects(item), g, &ctx.item(item)); let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() }; if carry.moves(&ap) { @@ -402,7 +402,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { last => Some(Node::group(c.id, copies.into_iter().chain(last).map(Arc::new).collect())), } } - None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + None if clipping => clipping_text_outline(c), None => as_art(Some(d), c, None), }; if let Some(o) = art { @@ -464,7 +464,7 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea /// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of /// its effects, and `n` without those effects, its gradients moved along with them. -fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { +pub(crate) fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { let (base, rule) = geometry(n)?; let ctx = GeomContext::of(n); let mut m = n.clone(); diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 2bcc026340..6d1cb35ebd 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -753,7 +753,7 @@ fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { } #[test] -fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { +fn type_in_a_clipping_group_with_a_fill_of_its_own_keeps_its_effect() { if skip_without_craft_fonts() { return; } @@ -768,9 +768,9 @@ fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { g.children_mut().unwrap().push(Arc::new(clip)); let d = bake_document(&doc_with(g)).unwrap(); assert_eq!(type_origins(&d), [(20.0, 60.0), (220.0, 60.0)]); - // The type clips by its plain outlines, as the canvas clips by them: its own effect is left out. + // The type clips by its outlines moved by its own effect, as the canvas clips by them. let b = d.node(NodeId(59)).and_then(|n| n.geometric_bounds()).unwrap(); - assert!(b.y0 > 30.0 && b.y1 < 70.0, "{b:?}"); + assert!(b.y0 > 330.0 && b.y1 < 370.0, "{b:?}"); } #[test] diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index a3e31f1842..e39deb32b5 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -7,9 +7,118 @@ use vectorcraft_geom::{BezPath, FillRule, PathData}; /// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their /// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of -/// its members. `None` when there is nothing to clip by (the clipped art is then hidden). +/// its members. The live geometry effects of the clipping path (and of the members it clips by) +/// reshape the region, as they reshape what it paints, and so do the geometry effects of a path's +/// fills and strokes: the region is then the union of the paths they give, as an export bakes them. +/// `None` when there is nothing to clip by (the clipped art is then hidden). pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { - clip.clip_outline(Some(&outline_text), &unite) + match clip_effects_applied(clip, true) { + Some(m) => m.clip_outline(Some(&outline_text), &unite), + None => clip.clip_outline(Some(&outline_text), &unite), + } +} + +/// `n`, the clipping path of a clip group, with the live geometry effects of everything it clips by +/// applied, innermost first, and removed: a path or compound path with effects becomes a path of +/// its reshaped geometry that keeps its fills, strokes and other effects (its gradients follow the +/// effects, as an export bakes it), the paths of a compound path and the visible members of a group +/// are reshaped by their own effects before the effects around them, a group's or layer's own +/// effects reshape its members as an export evaluates them, and other objects with effects (type) +/// become a path of their reshaped region. The geometry effects of a path's fills and strokes stay +/// live, for the export to bake. `None` when nothing in it has a geometry effect. Effects on the +/// clip group or around it then reshape the result, after its own. +pub(crate) fn with_clip_effects_applied(n: &Node) -> Option { + clip_effects_applied(n, false) +} + +/// [`with_clip_effects_applied`]; with `region` set, a path or compound path whose fills or strokes +/// have geometry effects becomes a group of one unpainted path per fill and stroke, reshaped by the +/// path's effects and then by that fill's or stroke's, whose union [`clip_outline`] clips by. +fn clip_effects_applied(n: &Node, region: bool) -> Option { + let mut m: Option = None; + let members: &[std::sync::Arc] = match &n.kind { + NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => children, + _ => &[], + }; + // A group or layer clips by its visible members after its shaper; a compound path by all of + // its paths, and a nested clip group by its clipping path, shown or hidden + // ([`Node::clip_shapes`]). + let all = matches!(n.kind, NodeKind::Compound { .. } | NodeKind::Group { clip: true, .. }); + let skip = usize::from(matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) && n.extras().shaper.is_some()); + // The paths of a compound path draw with the compound path's fills and strokes, and stay paths. + let compound = matches!(n.kind, NodeKind::Compound { .. }); + for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) { + if let Some(r) = clip_effects_applied(c, region && !compound) + && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = std::sync::Arc::new(r); + } + } + let items = region + && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i))); + if items && let Some((g, rule, ctx, p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { + let children = n + .appearance + .items + .iter() + .zip(&p.appearance.items) + .map(|(item, carried)| { + let (ig, _) = crate::bake::apply_item(item, carried, &g, &ctx); + let mut piece = Node::path(n.id, ig, Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut piece.kind { + *r = rule; + } + std::sync::Arc::new(piece) + }) + .collect(); + let mut out = Node::new(n.id, NodeKind::Group { children, clip: false }); + out.visible = n.visible; + return Some(out); + } + if !crate::has_geometry(&n.appearance.effects) { + return m; + } + let cur = m.as_ref().unwrap_or(n); + // A group's or layer's effects reshape each of its members (a clip group's its clipping + // path), as an export evaluates them; otherwise they reshape its region, below. + if matches!(cur.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) + && let Some(e) = crate::group::evaluate_container(None, cur) + { + return Some(e); + } + // A path or compound path, as an export bakes it. + if let Some((g, rule, _, mut p)) = crate::bake::leaf_base(cur) { + let clipping = match &cur.kind { + NodeKind::Path { clipping, .. } => *clipping, + NodeKind::Compound { children, .. } => children.iter().any(|c| matches!(c.kind, NodeKind::Path { clipping: true, .. })), + _ => false, + }; + p.kind = Node::path(n.id, g, Appearance::default()).kind; + if let NodeKind::Path { rule: r, clipping: c, .. } = &mut p.kind { + (*r, *c) = (rule, clipping); + } + return Some(p); + } + // Type, as its painted outlines that its effects reshape as an export evaluates them. + if matches!(cur.kind, NodeKind::Text(_)) + && let Some(mut o) = crate::outline_text(cur) + { + o.visible = n.visible; + o.appearance.effects = n.appearance.effects.clone(); + if let Some(e) = crate::group::evaluate_container(None, &o) { + return Some(e); + } + } + let (bp, rule) = cur.clip_outline(Some(&outline_text), &unite)?; + let (g, _) = crate::bake::apply(&n.appearance.effects, &PathData::from_bezpath(&bp), &crate::GeomContext::of(n)); + let mut p = Node::path(n.id, g, Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + p.visible = n.visible; + Some(p) } /// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index bb2f605a91..75f9bdd817 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -838,9 +838,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every /// depth of its groups and layers, by the art it draws ([`evaluate_container`], /// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members -/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the -/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with -/// everything in it. Calls nested in one another, through the +/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry +/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects +/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D +/// effect with everything in it. Calls nested in one another, through the /// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members /// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at /// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are. @@ -848,6 +849,13 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { let Some(_frame) = DrawnFrame::enter() else { return }; let clip = usize::from(n.clips()); let Some(ch) = n.children_mut() else { return }; + // The clipping path's own geometry effects shape it before the effects around it do. + if clip == 1 + && let Some(first) = ch.first_mut() + && let Some(applied) = crate::clip::with_clip_effects_applied(first) + { + *first = Arc::new(applied); + } for c in ch.iter_mut().skip(clip).filter(|c| c.visible) { if !DrawnFrame::take_member() { return; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c6e2a78c73..1411df4b44 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -747,9 +747,9 @@ fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() { assert!((w - 250.0).abs() < 1e-6, "{w}"); } -/// A clip group's clipping path keeps its own effects; the clipped members take theirs first. +/// A clip group's clipping path and the clipped members take their own effects first. #[test] -fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { +fn a_clipping_path_takes_its_own_effects_before_a_groups_effects() { let mut clip = with_effect(square_at_100(), offset_10()); clip.id = NodeId(2); if let NodeKind::Path { clipping, .. } = &mut clip.kind { @@ -762,7 +762,9 @@ fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { let clip_group = art.children().unwrap()[0].clone(); let ch = clip_group.children().unwrap(); assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind); - assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path"); + assert!(crate::reshape::geometry_effects(&ch[0]).is_empty(), "the clipping path's Offset Path is applied"); + let w = ch[0].geometric_bounds().unwrap().width(); + assert!((w - 60.0).abs() < 0.5, "grown by 10 on each side, then scaled to 50 %: {w}"); assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art"); } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 8c829d9e0b..78d9ee522a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -678,18 +678,28 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, } } +/// Type `t` in a clip group's clipping path as its outlines ([`outline_text`]) with its +/// visibility, or with geometry effects as a path of the region they give it, which is what +/// [`crate::clip_outline`] clips by. +pub(crate) fn clipping_text_outline(t: &Node) -> Option { + if let Some(p) = crate::clip::with_clip_effects_applied(t) { + return Some(p); + } + let o = outline_text(t)?; + Some(Node { visible: t.visible, ..o }) +} + /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes -/// its plain outlines ([`outline_text`]) with its visibility, which is what -/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting -/// their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and +/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); let art = match &c.kind { - NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), + NodeKind::Text(_) if clipping => clipping_text_outline(c), NodeKind::Text(_) => as_art(doc, c, None), _ => None, }; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 627cc1be03..5c04cb5deb 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod clipfx; mod imageoutline; mod painted; mod rasterfollow; diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs new file mode 100644 index 0000000000..c513be72c3 --- /dev/null +++ b/crates/effects/src/tests/clipfx.rs @@ -0,0 +1,315 @@ +//! Live effects on the clipping path of a clip group: the region it clips to takes the shape its +//! geometry effects give it, on the canvas as in an export. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Rect, Shape, shapes}; + +use crate::{bake_document, clip_outline}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// A 100 pt square clipping path at (50, 50) with `effects`. +fn clipping_path(id: u64, effects: Vec) -> Node { + let mut p = Node::path(NodeId(id), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut p.kind { + *clipping = true; + } + p.appearance.effects = effects; + p +} + +/// A clip group: `clip` clipping a red 200 pt square at the origin. +fn clip_group(id: u64, clip: Node) -> Node { + let art = Node::path( + NodeId(id + 100), + shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }) +} + +fn bounds(n: &Node) -> Rect { + clip_outline(n).map(|(bp, _)| bp.bounding_box()).unwrap() +} + +/// The clipping path of the first clip group in `doc` after an export bakes it. +fn baked_clip(doc: &Document) -> Arc { + fn first(n: &Arc) -> Option> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(first), + } + } + let baked = bake_document(doc).unwrap(); + baked.layers.iter().find_map(first).unwrap() +} + +#[test] +fn transform_moves_the_clip() { + let clip = clipping_path(1, vec![fx("distort.transform", json!({"moveH": 60}))]); + let b = bounds(&clip); + assert!((b.x0 - 110.0).abs() < 1e-6 && (b.x1 - 210.0).abs() < 1e-6, "the clip moves with the clipping path: {b:?}"); + assert!((b.y0 - 50.0).abs() < 1e-6 && (b.y1 - 150.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn roughen_clips_as_an_export_does() { + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let group = clip_group(1, clipping_path(2, vec![fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))])); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.1, exported.1); + assert_eq!(live.0, exported.0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0), "the roughened outline, not the plain square"); +} + +#[test] +fn nested_members_keep_their_effects() { + // A group as the clipping path: its member's Transform effect moves its part of the region. + let moved = clipping_path(2, vec![fx("distort.transform", json!({"moveV": 60}))]); + let group = Node::group(NodeId(1), vec![Arc::new(moved)]); + let b = bounds(&group); + assert!((b.y0 - 110.0).abs() < 1e-6 && (b.y1 - 210.0).abs() < 1e-6, "{b:?}"); + // A clip group as the clipping path clips by its own clipping path, effects and all. + let nested = clip_group(3, clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))])); + let b = bounds(&nested); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + // So does the nested clip group's own effect. + let mut nested = clip_group(7, clipping_path(8, vec![])); + nested.appearance.effects.push(fx("distort.transform", json!({"moveH": 30}))); + let b = bounds(&nested); + assert!((b.x0 - 80.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6, "{b:?}"); + // The group's own Transform effect applies on top of its members'. + let mut group = Node::group(NodeId(5), vec![Arc::new(clipping_path(6, vec![fx("distort.transform", json!({"moveV": 60}))]))]); + group.appearance.effects.push(fx("distort.transform", json!({"moveH": 10}))); + let b = bounds(&group); + assert!((b.x0 - 60.0).abs() < 1e-6 && (b.y0 - 110.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn a_roughened_group_clips_as_an_export_does() { + // Two members side by side, Roughen on the group: each member is roughened, as in the export. + let member = |id: u64, x: f64| { + Arc::new(Node::path( + NodeId(id), + shapes::rectangle(Rect::new(x, 50.0, x + 40.0, 150.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + )) + }; + let mut clip = Node::group(NodeId(2), vec![member(3, 50.0), member(4, 110.0)]); + clip.appearance.effects.push(fx("distort.roughen", json!({"size": 10, "detail": 10, "relative": false}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, clip.clone())).unwrap(); + let live = clip_outline(&clip).unwrap(); + assert_eq!(live.0, clip_outline(&baked_clip(&d)).unwrap().0, "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), Rect::new(50.0, 50.0, 150.0, 150.0)); +} + +#[test] +fn a_compound_paths_member_keeps_its_effect() { + // A ring whose hole a Transform effect moves 20 pt right, as the export bakes it. + let ring = |effects: Vec| { + let outer = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 150.0, 150.0)), Appearance::default()); + let mut hole = Node::path(NodeId(4), shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)), Appearance::default()); + hole.appearance.effects = effects; + Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(hole)], rule: vectorcraft_geom::FillRule::EvenOdd }) + }; + let moved = ring(vec![fx("distort.transform", json!({"moveH": 20}))]); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, moved.clone())).unwrap(); + let live = clip_outline(&moved).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves"); +} + +#[test] +fn the_clipping_paths_effects_apply_before_the_clip_groups() { + // Scale 50 % on the clipping path, then Offset Path 10 on its clip group: the region is the + // half-size square grown by 10, as if the clipping path had been drawn at half size. + let group = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + g + }; + let scaled = group(clipping_path(2, vec![fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))])); + let mut half = clipping_path(2, vec![]); + if let NodeKind::Path { path, .. } = &mut half.kind { + *path = shapes::rectangle(Rect::new(75.0, 75.0, 125.0, 125.0)); + } + let want = bounds(&group(half)); + let got = bounds(&scaled); + assert!((got.x0 - want.x0).abs() < 0.5 && (got.x1 - want.x1).abs() < 0.5, "{got:?} against {want:?}"); + assert!((want.x0 - 65.0).abs() < 0.5, "{want:?}"); + // The clip group as the canvas evaluates it for its effect, and as an export bakes it. + let evaluated = crate::evaluate_container(None, &scaled).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let b = clip_outline(&children[0]).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "evaluated: {b:?} against {want:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, scaled).unwrap(); + let b = clip_outline(&baked_clip(&d)).unwrap().0.bounding_box(); + assert!((b.x0 - want.x0).abs() < 0.5 && (b.x1 - want.x1).abs() < 0.5, "exported: {b:?} against {want:?}"); +} + +#[test] +fn nested_effects_apply_before_the_clip_groups() { + // The clip group grows its clipping path by 10 (Offset Path). Inside the clipping path a + // Transform moves a member 60 pt right: the region grows from the moved member. + let evaluated = |clip: Node| { + let mut g = clip_group(1, clip); + g.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + let e = crate::evaluate_container(None, &g).unwrap(); + let NodeKind::Group { children, clip: true } = &e.kind else { panic!("not a clip group") }; + clip_outline(&children[0]).unwrap().0.bounding_box() + }; + let moved = || clipping_path(3, vec![fx("distort.transform", json!({"moveH": 60}))]); + let close = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5; + let want = Rect::new(100.0, 40.0, 220.0, 160.0); + // A group holding the member. + let b = evaluated(Node::group(NodeId(2), vec![Arc::new(moved())])); + assert!(close(b, want), "group: {b:?}"); + // A compound path of the member, with a Transform of its own moving it 10 pt down. + let mut compound = Node::new(NodeId(2), NodeKind::Compound { children: vec![Arc::new(moved())], rule: vectorcraft_geom::FillRule::NonZero }); + compound.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let b = evaluated(compound); + assert!(close(b, Rect::new(100.0, 50.0, 220.0, 170.0)), "compound: {b:?}"); +} + +#[test] +fn clipping_type_keeps_its_effect_where_other_type_stays_type() { + // Type moved 30 pt right by its Transform clips a red square, beside other type, in a group + // that a Transform moves 10 pt down: the export keeps the other type as type and outlines the + // clipping type, which still clips where the canvas clips. + use vectorcraft_doc::text::{CharStyle, TextObject}; + let text = |id: u64, effects: Vec| { + let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut t = Node::new(NodeId(id), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style)))); + t.appearance.effects = effects; + t + }; + let clipped = clip_group(2, text(3, vec![fx("distort.transform", json!({"moveH": 30}))])); + let mut outer = Node::group(NodeId(1), vec![Arc::new(clipped), Arc::new(text(5, vec![]))]); + outer.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + fn clip_of(n: &Node) -> Option { + match &n.kind { + NodeKind::Group { children, clip: true } => Some(clip_outline(children.first()?)?.0.bounding_box()), + _ => n.children()?.iter().find_map(|c| clip_of(c)), + } + } + if vectorcraft_text::CRAFT_FONTS.is_empty() { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let plain = clip_outline(&text(3, vec![])).unwrap().0.bounding_box(); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()).unwrap(); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let mut exported = None; + for l in &baked.layers { + exported = exported.or_else(|| clip_of(l)); + } + let exported = exported.unwrap(); + assert!((canvas.x0 - plain.x0 - 30.0).abs() < 0.5 && (canvas.y0 - plain.y0 - 10.0).abs() < 0.5, "{canvas:?} from {plain:?}"); + assert!((exported.x0 - canvas.x0).abs() < 0.5 && (exported.y0 - canvas.y0).abs() < 0.5, "exported {exported:?}, canvas {canvas:?}"); + // The outlined clipping type keeps its black fill, which paints behind the clipped art. + let outlined = crate::reshape::clipping_text_outline(&text(3, vec![fx("distort.transform", json!({"moveH": 30}))])).unwrap(); + fn fills(n: &Node) -> usize { + n.appearance.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if f.paint != Paint::None)).count() + + n.children().into_iter().flatten().map(|c| fills(c)).sum::() + } + assert!(fills(&outlined) > 0, "{outlined:?}"); +} + +#[test] +fn a_painted_clipping_paths_gradient_follows_its_effect() { + // A clipping path filled with a gradient placed across it, moved 60 pt right by its Transform + // effect, in a clip group with an effect of its own: the gradient moves with the path. + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + let geom = GradientGeom { start: (50.0, 100.0).into(), end: (150.0, 100.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let mut clip = clipping_path(2, vec![fx("distort.transform", json!({"moveH": 60}))]); + clip.appearance = Appearance { effects: clip.appearance.effects.clone(), ..Appearance::basic(placed, Paint::None, 0.0) }.into(); + let mut group = clip_group(1, clip); + group.appearance.effects.push(fx("path.offsetPath", json!({"offset": 0}))); + let evaluated = crate::evaluate_container(None, &group).unwrap(); + let NodeKind::Group { children, clip: true } = &evaluated.kind else { panic!("not a clip group") }; + let Paint::Gradient(g) = children[0].appearance.fill_paint() else { panic!("no gradient") }; + let start = g.geom.unwrap().start; + assert!((start.x - 110.0).abs() < 1e-6, "the gradient starts at the moved path's left edge: {start:?}"); +} + +#[test] +fn a_nested_clip_groups_hidden_clipping_path_keeps_its_effect() { + // A clip group used as a clipping path clips by its clipping path whether it is shown or not. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": -30}))]); + hidden.visible = false; + let outer = clip_group(1, clip_group(3, hidden)); + d.insert(Some(layer), 0, outer.clone()).unwrap(); + let NodeKind::Group { children, .. } = &outer.kind else { panic!("not a group") }; + let b = bounds(&children[0]); + assert!((b.x0 - 20.0).abs() < 1e-6 && (b.x1 - 120.0).abs() < 1e-6, "{b:?}"); + assert_eq!(clip_outline(&children[0]), clip_outline(&baked_clip(&d)), "the canvas clips by the outline the export writes"); +} + +#[test] +fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() { + // The clipping path's fill has a Transform effect moving it 60 pt right; the path has none. + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + let mut clip = clipping_path(2, vec![]); + clip.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 0.0)), Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("distort.transform", json!({"moveH": 60}))); + } + } + let group = clip_group(1, clip); + d.insert(Some(layer), 0, group.clone()).unwrap(); + let NodeKind::Group { children, .. } = &group.kind else { panic!("not a group") }; + let live = clip_outline(&children[0]).unwrap(); + let exported = clip_outline(&baked_clip(&d)).unwrap(); + assert_eq!(live.0.bounding_box(), exported.0.bounding_box(), "the canvas clips by the outline the export writes"); + assert!((live.0.bounding_box().x1 - 210.0).abs() < 1e-6, "the moved fill reaches 210: {:?}", live.0.bounding_box()); + // A path of a compound clipping path with a fill effect of its own stays part of the region. + let mut member = clipping_path(4, vec![]); + member.appearance = children[0].appearance.clone(); + let other = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default()); + let compound = + Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(member), Arc::new(other)], rule: vectorcraft_geom::FillRule::NonZero }); + assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0)); +} + +#[test] +fn a_hidden_member_with_an_effect_adds_nothing() { + let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default()); + let mut hidden = clipping_path(4, vec![fx("distort.transform", json!({"moveH": 200}))]); + hidden.visible = false; + let group = Node::group(NodeId(2), vec![Arc::new(shown), Arc::new(hidden)]); + assert_eq!(bounds(&group), Rect::new(50.0, 50.0, 100.0, 100.0)); +} + +#[test] +fn raster_and_hidden_effects_leave_the_clip_as_it_is() { + let mut hidden = fx("distort.transform", json!({"moveH": 60})); + hidden.visible = false; + for effects in [vec![], vec![fx("blur.gaussian", json!({"radius": 10}))], vec![hidden]] { + assert_eq!(bounds(&clipping_path(1, effects)), Rect::new(50.0, 50.0, 150.0, 150.0)); + } +} diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 13a6dad415..9219ca6d57 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -664,6 +664,17 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let b = n.visual_bounds_with(symbols)?; let b = match &n.kind { NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), + // The geometry effects in a clipping path move or grow the region it clips to + // ([`effects::clip_outline`]) past its plain shape, and its stroke paints along it. + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { + Some(c) if effects::needs_bake(c) => { + let s = c.appearance.outset(); + let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); + let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; + vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) + } + _ => b, + }, _ => b, }; let a = live_content_reach(symbols, n); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index f649453663..15bd354496 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -122,7 +122,8 @@ impl Renderer { /// When a plug-in has been installed or removed since the last check /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, - /// of symbol instances and of the objects that hold them, and the art drawn for the effects of + /// of symbol instances and of the objects that hold them, the regions of clipping paths that + /// have one or hold one, and the art drawn for the effects of /// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, /// `live::resources_key`). @@ -148,6 +149,8 @@ impl Renderer { self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); // The art of a group holds what its members' plug-in effects draw. self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); + // A clipping path's plug-in effects shape the region it clips to. + self.clips.retain(|(node, _)| !holds_plugin_effect(node)); } } diff --git a/crates/render/src/tests/clip.rs b/crates/render/src/tests/clip.rs index 7b04c02e2d..cdd2028441 100644 --- a/crates/render/src/tests/clip.rs +++ b/crates/render/src/tests/clip.rs @@ -95,6 +95,42 @@ fn nothing_to_clip_by_hides_the_art() { assert_eq!(r.pixel(50, 50), CLEAR); } +#[test] +fn the_clipping_paths_geometry_effects_shape_the_clip() { + // A Transform effect moving the left half 50 pt right: the right half shows instead. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + let r = clipped(d, Arc::new(clip)); + assert_eq!(r.pixel(25, 50), CLEAR, "the clipping path's own place"); + assert_eq!(r.pixel(75, 50), BLACK, "where its effect moves it"); +} + +#[test] +fn a_view_of_only_the_moved_clip_draws_the_clipped_art() { + // The clipping path covers x 0 to 50 and its Transform effect moves it to x 50 to 100; the + // view shows x 60 to 100, past the clipping path's own shape. + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + clip.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }); + let art = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let members = vec![Arc::new(clip), Arc::new(art)]; + let g = Node::new(d.alloc_id(), NodeKind::Group { children: members.clone(), clip: true }); + // The same in a sublayer that clips by its first object. + let mut sublayer = (*d.layers[0]).clone(); + sublayer.id = d.alloc_id(); + if let NodeKind::Layer { children, clip, .. } = &mut sublayer.kind { + (*children, *clip) = (members, true); + } + for clipping in [g, sublayer] { + let mut d = d.clone(); + let l = d.layers[0].id; + d.insert(Some(l), 0, clipping).unwrap(); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip"); + } +} + #[test] fn clip_regions_are_cached_per_clipping_path() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/render/src/tests/container.rs b/crates/render/src/tests/container.rs index 89126cf2a2..dfc8dca91c 100644 --- a/crates/render/src/tests/container.rs +++ b/crates/render/src/tests/container.rs @@ -217,9 +217,9 @@ fn type_in_a_group_keeps_its_transparency_and_visibility_under_the_groups_geomet #[test] fn type_in_a_clipping_path_clips_alike_under_the_groups_geometry_effects() { - // The red square is clipped by type with a Transform of its own, which the clip leaves out, - // then by a group of that type and a small square, with the type shown and hidden. Offset - // Path 0 on the clip group leaves every path as it is. + // The red square is clipped by type with a Transform of its own, then by a group of that type + // and a small square, with the type shown and hidden. Offset Path 0 on the clip group leaves + // every path as it is, so the clip group draws the same with it and without it. let doc = |effect: bool, grouped: bool, hidden: bool| { let mut d = Document::new(100.0, 100.0); let style = CharStyle { size: 40.0, fill: Paint::None, ..Default::default() }; diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index d286fa7e3c..e60114708f 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1667,6 +1667,16 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() { plugin_change_draws_as_new(alone(20.0, vec![drop_shadow()]), ID, small.clone(), big.clone()); let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0})); plugin_change_draws_as_new(alone(20.0, vec![blur]), ID, small.clone(), big.clone()); + // A clip group whose clipping path, the square, has the plug-in effect: it clips a red + // rectangle over the whole view by the shape the plug-in draws. + let mut d = Document::new(200.0, 100.0); + let mut clip = square(&mut d, 20.0, 30.0, vec![]); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]); + let group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(red)], clip: true }); + plugin_change_draws_as_new(with_art(d, vec![group]), ID, small.clone(), big.clone()); // An instance of symbol `Shaped`, a group of the square around the origin. let mut d = Document::new(200.0, 100.0); let art = square(&mut d, -20.0, -20.0, vec![]); diff --git a/crates/svg/tests/it/effects_export.rs b/crates/svg/tests/it/effects_export.rs index 832e72c8f8..c1a19e8d53 100644 --- a/crates/svg/tests/it/effects_export.rs +++ b/crates/svg/tests/it/effects_export.rs @@ -102,6 +102,37 @@ fn a_strokes_own_glow_filters_the_stroke_element_only() { assert!(!export(&doc_with(n), &ExportOptions::default()).contains("").expect("its end")]; + body[body.find('>').unwrap()..].to_string() + }; + assert_eq!(clipped(0.0, true), clipped(50.0, false), "the clip path is written where the effect moves it"); + assert_ne!(clipped(0.0, true), clipped(0.0, false)); +} + #[test] fn geometry_effects_on_type_export_as_its_reshaped_outlines() { let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ee72f2cbe8..33cad82654 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1552,6 +1552,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. - **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks. + The geometry effects of a clipping path (Roughen, Transform and the other effects that reshape a path), of its fills and strokes, and of the members of a group used as one, shape the area it clips to on the canvas and in raster export, as in SVG and PDF export: a roughened clipping path clips by its roughened outline. They apply before the geometry effects of the clip group or of a group around it, on the canvas and in every export, and type used as a clipping path keeps them when a group's effect outlines it. Raster effects such as Gaussian Blur leave that area as it is. `effects::tests::clipfx` and the renderer's `tests::clip` cover it. - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. From 7e63d4ca0c7d6b130c671d8411157ac085d5945c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:48:25 -0800 Subject: [PATCH 8/9] Recovery: a recovery copy that finishes later keeps a newer status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recovery copy written in the background set "Saved recovery data for " (or its failure line) when it finished, over any message shown since it started, such as Restore's count with its notes or the "Saving…" line of a copy started after it. The status bar now counts the messages VectorcraftApp::status sets. A copy shows its result only while its own "Saving recovery data for …" line is still the latest message, or right away when it was written without the worker. A failure is still logged, and a failed folder is still recorded for automation replies, when its line is not shown. --- crates/ui-egui/src/app/commands.rs | 1 + crates/ui-egui/src/recovery.rs | 66 ++++++++++++++-------- crates/ui-egui/src/state.rs | 5 ++ crates/ui-egui/src/tests/recovery.rs | 82 ++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 5 files changed, 131 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 50c7b1be30..6d40499106 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -457,5 +457,6 @@ impl VectorcraftApp { /// Show a transient status message. pub fn status(&mut self, s: impl Into<String>) { self.ui.status = s.into(); + self.ui.status_sets = self.ui.status_sets.wrapping_add(1); } } diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index aa0a25e50f..9d787954ea 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -125,24 +125,37 @@ fn write(app: &mut VectorcraftApp, store: &Arc<dyn RecoveryStore>, pick: impl Fn for job in jobs { let (uid, label, title) = (job.uid, format!("Saving recovery data for {}", job.title), job.title.clone()); let (done, at, saving) = (job.clone(), place.clone(), label.clone()); + // The status line before the job starts: on the worker, the job sets one line ("<label>…") + // right after this; written right away, it sets none before `then` runs. + let (sets, started_line) = (app.ui.status_sets, format!("{label}…")); let work = move |_: Writer| job.write(); // A failure is reported here, so the job's result is not an error: that would show as // "<label> failed: <error>" over this line, or as the round's message when it ran here. - let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| match (r, at) { - (Ok(r), _) => { - done.finish(&mut app.session); - app.status(format!("Saved recovery data for {title}")); - Ok(r) - } - (Err(e), Some(at)) => { - copy_failed(app, &title, &at, &e); - Ok(Value::Null) - } - // A store the host installed has no folder to name: the job's own line. - (Err(e), None) => { - app.status(format!("{saving} failed: {e}")); - log::warn!("vectorcraft: {}", app.ui.status); - Ok(Value::Null) + let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| { + // The result goes to the status bar only while it still shows this job's own line: a + // message set since then (another job's, a restore's with its notes) stays. + let show = app.ui.status_sets == sets || (app.ui.status_sets == sets.wrapping_add(1) && app.ui.status == started_line); + match (r, at) { + (Ok(r), _) => { + done.finish(&mut app.session); + if show { + app.status(format!("Saved recovery data for {title}")); + } + Ok(r) + } + (Err(e), Some(at)) => { + copy_failed(app, &title, &at, &e, show); + Ok(Value::Null) + } + // A store the host installed has no folder to name: the job's own line. + (Err(e), None) => { + let line = format!("{saving} failed: {e}"); + log::warn!("vectorcraft: {line}"); + if show { + app.status(line); + } + Ok(Value::Null) + } } }; // Its own store, not the file writer: this only decides whether it runs on the worker. @@ -173,18 +186,23 @@ fn failed(app: &mut VectorcraftApp, place: Option<&(String, bool)>, e: &str) { log::warn!("vectorcraft: {}", app.ui.status); } -/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`. -fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str) { - match place { - (folder, true) => app.status(format!( +/// [`failed`] for the copy of document `title` that couldn't be written to folder `place`, shown +/// in the status bar only when `show` holds. The log always has it, and the folder is recorded +/// in [`Timer::named`] either way. +fn copy_failed(app: &mut VectorcraftApp, title: &str, place: &(String, bool), e: &str, show: bool) { + let line = match place { + (folder, true) => format!( "Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)" - )), - (folder, false) => app.status(format!( + ), + (folder, false) => format!( "Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder)" - )), - } + ), + }; named(app, &place.0); - log::warn!("vectorcraft: {}", app.ui.status); + log::warn!("vectorcraft: {line}"); + if show { + app.status(line); + } } /// Record that the status text named recovery folder `folder` ([`Timer::named`]). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a29ca50e41..d06c0dbcb2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -574,6 +574,10 @@ pub struct UiState { #[serde(skip)] pub touch_drawing_tool: Option<String>, pub status: String, + /// How many times [`crate::VectorcraftApp::status`] has set `status`, so that work finishing + /// later can tell whether the line it started with is still the latest one. Not saved. + #[serde(skip)] + pub status_sets: u64, /// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved. #[serde(skip)] pub replace_font_warning: Option<ReplaceFontWarning>, @@ -1163,6 +1167,7 @@ impl Default for UiState { group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(), touch_drawing_tool: None, status: String::new(), + status_sets: 0, replace_font_warning: None, status_notes: None, palette_open: false, diff --git a/crates/ui-egui/src/tests/recovery.rs b/crates/ui-egui/src/tests/recovery.rs index 76f56a1fb9..946c535fd4 100644 --- a/crates/ui-egui/src/tests/recovery.rs +++ b/crates/ui-egui/src/tests/recovery.rs @@ -414,6 +414,88 @@ fn a_copy_that_fails_in_a_host_store_names_the_document() { } } +/// Services that write recovery copies on the background worker. +fn on_the_worker() -> Services { + Services { write_shared: Some(Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() } +} + +/// A copy that finishes after a newer message (a restore's, with its notes) was shown is still +/// written, and the newer message and its notes stay in the status bar. +#[test] +fn a_copy_that_finishes_later_keeps_a_newer_status_and_its_notes() { + let store = Arc::new(MemoryStore::default()); + let mut app = app_with(&store, on_the_worker()); + recovery::tick(&mut app, 0.0); + assert_eq!(recovery::tick(&mut app, 120.0), 1); + assert_eq!(app.background.jobs.len(), 1, "on the worker"); + let newer = "Restored 2 documents with 2 notes: one".to_string(); + crate::io::show_with_notes(&mut app, newer.clone(), vec!["one".into(), "two".into()]); + background::wait_all(&mut app); + assert_eq!(copies(&store), 1); + assert!(app.session.doc().unwrap().recovery.is_some()); + assert_eq!(app.ui.status, newer); + assert_eq!(app.ui.status_notes, Some((newer, vec!["one".into(), "two".into()]))); +} + +/// Of two copies started in one round, the first one to finish leaves the second one's +/// "Saving…" line, and the second one reports. +#[test] +fn a_copy_that_finishes_leaves_the_line_of_a_copy_started_after_it() { + let store = Arc::new(MemoryStore::default()); + let mut app = app_with(&store, on_the_worker()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + recovery::tick(&mut app, 0.0); + assert_eq!(recovery::tick(&mut app, 120.0), 2); + let second = app.ui.status.clone(); + let Some(title) = second.strip_prefix("Saving recovery data for ").and_then(|s| s.strip_suffix('…')) else { + panic!("{second}"); + }; + let title = title.to_string(); + background::wait_until(&mut app, |app| app.background.jobs.len() == 1); + assert_eq!(app.ui.status, second); + background::wait_all(&mut app); + assert_eq!(copies(&store), 2); + assert_eq!(app.ui.status, format!("Saved recovery data for {title}")); +} + +/// A copy that fails after a newer message was shown leaves that message in the status bar, in a +/// store the host installed and in a recovery folder; the folder is still recorded. +#[test] +fn a_copy_that_fails_later_keeps_a_newer_status() { + let store: Arc<dyn RecoveryStore> = Arc::new(Refusing(MemoryStore::default())); + let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store), ..on_the_worker() }); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + app.session.prefs.autosave_interval = 2; + recovery::tick(&mut app, 0.0); + assert_eq!(recovery::tick(&mut app, 120.0), 1); + app.status("Newer"); + background::wait_all(&mut app); + assert_eq!(app.ui.status, "Newer"); + + let dir = scratch("later"); + let folder = dir.join("recovery"); + let shown = folder.to_string_lossy().into_owned(); + let mut app = app_in_folder(&shown, on_the_worker()); + assert_eq!(recovery::tick(&mut app, 120.0), 1); + background::wait_all(&mut app); + // A folder with a file in it takes the copy's place, so the next copy can't be written. + let name = app.session.doc().unwrap().recovery.as_ref().unwrap().file.clone(); + let copy = folder.join(format!("{name}.vectorcraft")); + std::fs::remove_file(©).unwrap(); + std::fs::create_dir(©).unwrap(); + std::fs::write(copy.join("in-the-way"), b"").unwrap(); + app.run("shape.ellipse", json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap(); + assert_eq!(recovery::tick(&mut app, 240.0), 1); + app.status("Newer"); + background::wait_all(&mut app); + assert_eq!(app.ui.status, "Newer"); + assert_eq!(app.recovery.named, std::slice::from_ref(&shown)); + drop(app); + let _ = std::fs::remove_dir_all(&dir); +} + /// Issue #367: a tab whose timers were paused (in the background) long enough for another tab to /// take it for gone and discard its copy writes the copy again on its first heartbeat after it /// resumes, even with the timer off (the copy was written by hand). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..894cb730a8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1640,7 +1640,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only. `prefs.set` applies the dialog's check to `recoveryFolder` and `pluginsFolder` for every caller (VectorCraft's own rule): a folder that fails returns an error and no preference changes; an empty value and the folder already saved are not checked. - When a Data Recovery copy can't be written, the status bar and the log show the error (with the document's title when one document's copy fails), the folder copies go to, whether that is the default recovery folder or the folder set in Preferences, and the preference that picks another folder (`recoveryFolder`; VectorCraft's own message). + When a Data Recovery copy can't be written, the status bar and the log show the error (with the document's title when one document's copy fails), the folder copies go to, whether that is the default recovery folder or the folder set in Preferences, and the preference that picks another folder (`recoveryFolder`; VectorCraft's own message). A copy written in the background reports its result in the status bar only while the status bar still shows that copy's own "Saving recovery data for …" line, so a message shown since then, such as Restore's count with its notes or another copy's line, stays; a failure is still written to the log (VectorCraft's own rule). After Restore in the dialog Data Recovery shows at launch, the status bar keeps the restored documents' notes (VectorCraft's own rule): it shows the count followed by the first note, and hovering it lists each document's lines about its linked files and its warnings, named with the document's title when more than one was restored. The questions about the documents' missing or modified linked files come one document at a time, with that document made the current one, and a copy that can't be restored shows its error while the hover keeps the count and the restored documents' notes (`tests/recovery.rs`). A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). A name returned by the desktop save panel with the extension of a format the save does not write has it replaced with the saved format's: Save As of a new document saves `card.ait` and `card.png` as `card.vectorcraft`, and Save as Template saves `card.pdf` as `card.vctemplate`. An extension that names no format stays part of the name (`notes.v2` is saved as `notes.v2.vectorcraft`). From fb8cf27b9a4677e80b6cc84dcd13e25ac1d0dcf6 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:52:45 -0800 Subject: [PATCH 9/9] Effects: clip regions follow fill effects and compound paths in every case Three cases still clipped differently from what the clipping path draws: - A clip group with an appearance of its own (a Drop Shadow, a geometry effect) was measured for culling by its plain clipping path, so a view showing only the part of the clip that an effect moved drew nothing. The renderer now grows that measure by the clip region in this case too. - The geometry effects on the fills and strokes of an unpainted clipping path applied after the effects of a group around it. They now apply first, as for other members. A clipping path that paints keeps them live, so its fill and stroke still draw. - Export baked a compound clipping path with an effect of its own from its raw paths, dropping the effects on those paths. It now bakes the clipping path as the canvas clips by it, and the pieces it bakes into get ids of their own. --- crates/effects/src/bake.rs | 19 ++++++- crates/effects/src/clip.rs | 38 +++++++------ crates/effects/src/tests/clipfx.rs | 86 ++++++++++++++++++++++++++++++ crates/render/src/fx/bounds.rs | 33 +++++++----- crates/render/src/tests/clip.rs | 37 +++++++++++++ 5 files changed, 182 insertions(+), 31 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index adbdf20b48..27d3194eb3 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -39,7 +39,7 @@ pub(crate) fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] } } -fn clear_item_effects(item: &mut AppearanceItem) { +pub(crate) fn clear_item_effects(item: &mut AppearanceItem) { match item { AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), @@ -202,7 +202,22 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> { return bake_leaf(d, n); } let mut m = n.clone(); - let baked: Vec<Arc<Node>> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + // A clipping path takes the geometry effects in it as the canvas clips by it, those of a + // compound path's paths before the compound path's own ([`crate::clip::with_clip_effects_applied`]); + // the pieces it is made of then get ids of their own. + let clip = usize::from(n.clips()); + let baked: Vec<Arc<Node>> = ch + .iter() + .enumerate() + .map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() { + Some(a) => { + let mut a = bake_node(d, &a).unwrap_or(a); + fresh_ids(d, &mut a, &mut std::collections::HashSet::new()); + Arc::new(a) + } + None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()), + }) + .collect(); if let Some(ch) = m.children_mut() { *ch = baked; } diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index e39deb32b5..60cc3f2845 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -24,17 +24,21 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> { /// effects, as an export bakes it), the paths of a compound path and the visible members of a group /// are reshaped by their own effects before the effects around them, a group's or layer's own /// effects reshape its members as an export evaluates them, and other objects with effects (type) -/// become a path of their reshaped region. The geometry effects of a path's fills and strokes stay -/// live, for the export to bake. `None` when nothing in it has a geometry effect. Effects on the -/// clip group or around it then reshape the result, after its own. +/// become a path of their reshaped region. A path or compound path whose fills or strokes have +/// geometry effects becomes a group of one path per fill and stroke, reshaped by the path's effects +/// and then by that fill's or stroke's, as an export bakes it; [`clip_outline`] clips by their +/// union. A clipping path that paints ([`Node::clip_paint`], which a group does not) keeps the +/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a +/// geometry effect. Effects on the clip group or around it then reshape the result, after its own. pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> { - clip_effects_applied(n, false) + let paint = n.clip_paint(); + clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none()) } -/// [`with_clip_effects_applied`]; with `region` set, a path or compound path whose fills or strokes -/// have geometry effects becomes a group of one unpainted path per fill and stroke, reshaped by the -/// path's effects and then by that fill's or stroke's, whose union [`clip_outline`] clips by. -fn clip_effects_applied(n: &Node, region: bool) -> Option<Node> { +/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of +/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of +/// `n`'s own fills and strokes are left out. +fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> { let mut m: Option<Node> = None; let members: &[std::sync::Arc<Node>] = match &n.kind { NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(), @@ -49,33 +53,35 @@ fn clip_effects_applied(n: &Node, region: bool) -> Option<Node> { // The paths of a compound path draw with the compound path's fills and strokes, and stay paths. let compound = matches!(n.kind, NodeKind::Compound { .. }); for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) { - if let Some(r) = clip_effects_applied(c, region && !compound) + if let Some(r) = clip_effects_applied(c, items && !compound) && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) { *slot = std::sync::Arc::new(r); } } - let items = region + let items = items && matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.appearance.items.iter().any(|i| crate::has_geometry(crate::bake::item_effects(i))); - if items && let Some((g, rule, ctx, p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { + if items && let Some((g, rule, ctx, mut p)) = crate::bake::leaf_base(m.as_ref().unwrap_or(n)) { let children = n .appearance .items .iter() .zip(&p.appearance.items) .map(|(item, carried)| { - let (ig, _) = crate::bake::apply_item(item, carried, &g, &ctx); - let mut piece = Node::path(n.id, ig, Appearance::default()); + let (ig, mut it) = crate::bake::apply_item(item, carried, &g, &ctx); + crate::bake::clear_item_effects(&mut it); + let mut piece = Node::path(n.id, ig, Appearance { items: vec![it], ..Default::default() }); if let NodeKind::Path { rule: r, .. } = &mut piece.kind { *r = rule; } std::sync::Arc::new(piece) }) .collect(); - let mut out = Node::new(n.id, NodeKind::Group { children, clip: false }); - out.visible = n.visible; - return Some(out); + // The path's transparency and raster effects stay on the group of its pieces. + p.appearance.items.clear(); + p.kind = NodeKind::Group { children, clip: false }; + return Some(p); } if !crate::has_geometry(&n.appearance.effects) { return m; diff --git a/crates/effects/src/tests/clipfx.rs b/crates/effects/src/tests/clipfx.rs index c513be72c3..370f32fa81 100644 --- a/crates/effects/src/tests/clipfx.rs +++ b/crates/effects/src/tests/clipfx.rs @@ -131,6 +131,15 @@ fn a_compound_paths_member_keeps_its_effect() { let live = clip_outline(&moved).unwrap(); assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); assert_ne!(live, clip_outline(&ring(vec![])).unwrap(), "the hole moves"); + // With a Transform of the compound path's own moving the whole ring 10 pt down after it. + let mut both = moved.clone(); + both.appearance.effects.push(fx("distort.transform", json!({"moveV": 10}))); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(1, both.clone())).unwrap(); + let live = clip_outline(&both).unwrap(); + assert_eq!(live, clip_outline(&baked_clip(&d)).unwrap(), "the canvas clips by the outline the export writes"); + assert_ne!(live.0.bounding_box(), clip_outline(&moved).unwrap().0.bounding_box(), "the ring moves down"); } #[test] @@ -296,6 +305,83 @@ fn a_fills_geometry_effect_shapes_the_clip_as_an_export_does() { assert_eq!(bounds(&compound), Rect::new(0.0, 0.0, 150.0, 150.0)); } +#[test] +fn a_fills_geometry_effect_shapes_the_clip_before_the_effects_around_it() { + // Offset Path 10 on the fill of an unpainted clipping path, in a group that its Transform + // scales to 50 %: the region is the grown square at half size, 60 pt across, as it is when the + // clipping path is the member of a group that clips by its members. + let clip_with = |fill: Paint| { + let mut clip = clipping_path(3, vec![]); + clip.appearance = Appearance::basic(fill, Paint::None, 0.0).into(); + for item in &mut clip.appearance.items { + if let vectorcraft_doc::AppearanceItem::Fill(f) = item { + f.effects.push(fx("path.offsetPath", json!({"offset": 10}))); + } + } + clip + }; + let scaled = |clip: Node| { + let mut outer = Node::group(NodeId(1), vec![Arc::new(clip_group(2, clip))]); + outer.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50}))); + outer + }; + fn first_clip(n: &Node) -> Option<Arc<Node>> { + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => n.children()?.iter().find_map(|c| first_clip(c)), + } + } + let clip_of = |n: &Node| clip_outline(&first_clip(n).unwrap()).unwrap().0.bounding_box(); + let close = |r: Rect| (r.width() - 60.0).abs() < 0.5 && (r.height() - 60.0).abs() < 0.5; + let nested = clip_of(&crate::evaluate_container(None, &scaled(Node::group(NodeId(4), vec![Arc::new(clip_with(Paint::None))]))).unwrap()); + assert!(close(nested), "nested: {nested:?}"); + let outer = scaled(clip_with(Paint::None)); + let canvas = clip_of(&crate::evaluate_container(None, &outer).unwrap()); + assert!(close(canvas), "canvas: {canvas:?}"); + let mut d = Document::new(400.0, 400.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + let exported = baked.layers.iter().find_map(|l| first_clip(l)).map(|c| clip_outline(&c).unwrap().0.bounding_box()).unwrap(); + assert!(close(exported), "exported: {exported:?}"); + // A clipping path that paints its fill keeps it, a path with the fill's effect live. + let painted = first_clip(&crate::evaluate_container(None, &scaled(clip_with(Paint::solid(Color::BLACK)))).unwrap()).unwrap(); + assert!(painted.clip_paint().fill.is_some(), "{painted:?}"); +} + +#[test] +fn a_baked_clipping_path_gives_its_pieces_ids_of_their_own() { + // An unpainted clipping path whose fill and stroke have Transform effects, in a clip group + // without effects: the export bakes it into a piece for each, every object with its own id. + let mut d = Document::new(400.0, 400.0); + let (group, path) = (d.alloc_id(), d.alloc_id()); + let mut clip = clipping_path(path.0, vec![]); + clip.appearance = Appearance::basic(Paint::None, Paint::None, 1.0).into(); + for item in &mut clip.appearance.items { + match item { + vectorcraft_doc::AppearanceItem::Fill(f) => f.effects.push(fx("distort.transform", json!({"moveH": 60}))), + vectorcraft_doc::AppearanceItem::Stroke(s) => s.effects.push(fx("distort.transform", json!({"moveV": 60}))), + } + } + let layer = d.layers[0].id; + d.insert(Some(layer), 0, clip_group(group.0, clip)).unwrap(); + let baked = bake_document(&d).unwrap(); + let clip = baked_clip(&d); + assert_eq!(clip.children().map(|c| c.len()), Some(2), "{clip:?}"); + fn ids(n: &Node, out: &mut Vec<NodeId>) { + out.push(n.id); + for c in n.children().into_iter().flatten() { + ids(c, out); + } + } + let mut all = vec![]; + for l in &baked.layers { + ids(l, &mut all); + } + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), all.len(), "{all:?}"); +} + #[test] fn a_hidden_member_with_an_effect_adds_nothing() { let shown = Node::path(NodeId(3), shapes::rectangle(Rect::new(50.0, 50.0, 100.0, 100.0)), Appearance::default()); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 7816d3f187..65ce8a91fd 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -623,7 +623,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); let b = if art.clips() { - art.visual_bounds_with(symbols) + art.visual_bounds_with(symbols).map(|b| with_clip_region(symbols, art, b)) } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c))) }?; @@ -671,18 +671,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b let b = n.visual_bounds_with(symbols)?; let b = match &n.kind { NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), - // The geometry effects in a clipping path move or grow the region it clips to - // ([`effects::clip_outline`]) past its plain shape, and its stroke paints along it. - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { - Some(c) if effects::needs_bake(c) => { - let s = c.appearance.outset(); - let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); - let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; - vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) - } - _ => b, - }, - _ => b, + _ => with_clip_region(symbols, n, b), }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) @@ -690,6 +679,24 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: b } } +/// `b`, the visual bounds of `n`, grown to the region a clip group's or clipping layer's clipping +/// path clips to when geometry effects in it move or grow that region ([`effects::clip_outline`]) +/// past its plain shape, and to where its stroke paints along it. Other objects keep `b`. +fn with_clip_region(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect { + match &n.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => match children.first() { + Some(c) if effects::needs_bake(c) => { + let s = c.appearance.outset(); + let region = effects::clip_outline(c).map(|(bp, _)| bp.bounding_box().inflate(s, s)); + let painted = if has_fx(c) { visual_bounds(symbols, c) } else { None }; + vectorcraft_geom::union_opt(vectorcraft_geom::union_opt(Some(b), region), painted).unwrap_or(b) + } + _ => b, + }, + _ => b, + } +} + /// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate), /// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no /// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps diff --git a/crates/render/src/tests/clip.rs b/crates/render/src/tests/clip.rs index cdd2028441..205c2a2583 100644 --- a/crates/render/src/tests/clip.rs +++ b/crates/render/src/tests/clip.rs @@ -131,6 +131,43 @@ fn a_view_of_only_the_moved_clip_draws_the_clipped_art() { } } +#[test] +fn a_view_of_only_the_moved_clip_draws_a_clip_group_with_its_own_appearance() { + // As above, with the Transform effect on the clipping path itself or on its fill, and the clip + // group with an effect of its own: a Drop Shadow, or a Transform that moves nothing. + let moved = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 50}), visible: true }; + let shadow = vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 0, "y": 6, "blur": 0, "opacity": 100}), + visible: true, + }; + let still = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveV": 0}), visible: true }; + for on_fill in [false, true] { + for own in [shadow.clone(), still.clone()] { + let mut d = Document::new(100.0, 100.0); + let mut clip = Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 50.0, 100.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + match clip.appearance.items.iter_mut().find_map(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) if on_fill => Some(f), + _ => None, + }) { + Some(f) => f.effects.push(moved()), + None => clip.appearance.effects.push(moved()), + } + let art = + Node::path(d.alloc_id(), rect(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + g.appearance.effects.push(own.clone()); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let what = format!("effect on the fill: {on_fill}, group effect {}", own.id); + let whole = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(whole.pixel(80, 50), BLACK, "the whole canvas ({what})"); + let r = Renderer::new().render(&d, 40, 100, Affine::translate((-60.0, 0.0)), &RenderOptions::default()); + assert_eq!(r.pixel(20, 50), BLACK, "x 80, inside the moved clip ({what})"); + } + } +} + #[test] fn clip_regions_are_cached_per_clipping_path() { let mut d = Document::new(100.0, 100.0);