From 1a77932167c838ebb992bbccd5f60a25ccfcd040 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:11:12 -0800 Subject: [PATCH 01/35] .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 02/35] 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 03/35] 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 4129587abee1c7a80162129211e5f8e9c04570a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 15:22:55 -0800 Subject: [PATCH 04/35] Graphs: recolor, stroke and gradient edits on labels are kept for their kind A recolor of a graph (Invert and Complement on the proxy, the Edit Colors commands, Recolor Artwork, the color filters, Apply Nearest Swatch and the ink limit of Print Ink Fixes) stored the new paint of the series marks but not of the labels. stroke.set, the stroke weight steps, the gradient edits, Swap Fill and Stroke and Default Fill and Stroke stored nothing when they changed a label's characters. When the graph was drawn again, the labels went back to their stored style. - These commands now store, for every label of the kind they reach, the label's object rows when they change, and the fill, stroke, stroke weight, caps, joins, miter limit, dashes and stroke alignment of its characters when all of them show the change alike. - A gradient is stored without its placement, and each label fits it to its own box. A stroke weight and dashes are stored as a scaled label shows them. - When labels of one kind change differently, the frontmost one sets what the kind keeps. - On a pie with Legends in Wedges, the legend labels' fill is stored only when every legend label reached ends up with the same fill, so a recolor of the whole pie leaves them black or white by their wedge. - Axis lines, ticks and radar rings are not stored and are drawn again with their default paint. Docs: docs/mcp.md, the commands' params text, docs/target-app-parity.md. --- crates/engine/src/cmd/addons/color.rs | 6 +- crates/engine/src/cmd/appearance.rs | 15 +- crates/engine/src/cmd/colorcmds.rs | 10 +- crates/engine/src/cmd/freeform.rs | 2 +- crates/engine/src/cmd/gradient.rs | 4 +- crates/engine/src/cmd/graph.rs | 7 +- crates/engine/src/cmd/graph/generate.rs | 5 +- crates/engine/src/cmd/graph/series.rs | 165 ++++++++++++++++- crates/engine/src/cmd/graph/tests.rs | 237 ++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 12 +- crates/engine/src/cmd/recolor.rs | 2 +- crates/engine/src/cmd/stroke.rs | 4 +- docs/mcp.md | 13 +- docs/target-app-parity.md | 2 +- 14 files changed, 443 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index fa8182c4c2..8701d379f7 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed}", has_selection, filter ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Print Ink Fixes", ["Object", "Plug-ins", "Color Effects"], None, - "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", has_doc, print_fixes::run ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index e30ba811d4..7395db5cea 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -508,9 +508,11 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke /// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). -/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on -/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and -/// when `f` leaves every target as it was, the document and its undo history stay as they are. +/// What the edit changes on the topmost visible fill and stroke of graph series marks, and on the +/// rows and characters of graph labels, is stored on their graphs in the same step +/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not +/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as +/// they are. pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], @@ -520,7 +522,12 @@ pub(crate) fn edit_items( fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, ) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) + edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| { + let series = capture_series_changes(before, d, edited); + // The rows `f` edited are kept by `edit_items_then`. + let labels = super::graph::capture_label_changes(before, d, edited, false); + Ok(series || labels) + }) } /// [`edit_items`], then `after` on the document as it was before the edit, the edited document and diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 6de2fbf9a2..7ad53d5bf5 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -486,14 +486,16 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - } /// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed -/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn -/// again. +/// on the graph series marks and graph labels among `roots` and inside them, so the marks and every label of a kind keep +/// their new paint when the graph is drawn again. pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result) -> Result { let marks = super::graph::series_marks_in(d, roots); - let before = (!marks.is_empty()).then(|| d.clone()); + let labels = super::graph::labels_in(d, roots); + let before = (!marks.is_empty() || !labels.is_empty()).then(|| d.clone()); let n = recolor(d)?; if let Some(before) = before.filter(|_| n > 0) { super::appearance::capture_series_changes(&before, d, &marks); + super::graph::capture_label_changes(&before, d, &labels, true); } Ok(n) } diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index bdba729211..5f2a5e4070 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind; the points on a label's characters are not stored → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 54ca07da7e..8062d6110a 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without its placement, fitted to each label", has_doc, edit_gradient ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters their gradient without the placement this command sets, fitted to each label", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index e283afd752..0413a55dbb 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -45,9 +45,10 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, - capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, label_object_opacity_differs, - paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, shown_paint, + LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, + capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, + label_object_opacity_differs, labels_in, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, + series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index a789304b24..60217105d8 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,7 @@ use super::labels::{ recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::{series_marks, with_series_opacity}; +use super::series::{picks_legend_fill, series_marks, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -397,8 +397,7 @@ impl<'a> Plot<'a> { let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62); let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true); // White on a dark wedge, unless the legend labels were given a colour of their own. - let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); - self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); + self.wedge_labels.push(if dark && picks_legend_fill(self.g) { recolour_text(&t, Color::WHITE) } else { t }); } /// The pie's category name. diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index bfde9e881a..806d95047f 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -487,20 +487,27 @@ pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], metho pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { let mut changed = false; for (graph, role) in label_kinds(doc, ids) { - let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - let mut style = label_style(&spec.label_styles, role); - change(&mut style); - let slot = match role { - Role::Value => &mut spec.label_styles.values, - Role::Category => &mut spec.label_styles.categories, - Role::Legend => &mut spec.label_styles.legend, - }; - changed |= slot.as_ref() != Some(&style); - *slot = Some(style); + changed |= store_label_style(doc, graph, role, change); } changed } +/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether +/// the kept style changed. +fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false }; + let mut style = label_style(&spec.label_styles, role); + change(&mut style); + let slot = match role { + Role::Value => &mut spec.label_styles.values, + Role::Category => &mut spec.label_styles.categories, + Role::Legend => &mut spec.label_styles.legend, + }; + let changed = slot.as_ref() != Some(&style); + *slot = Some(style); + changed +} + /// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels /// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character /// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes. @@ -668,6 +675,144 @@ pub(crate) fn capture_label_text( Ok(changed) } +/// The character fill and stroke attributes a kind of graph label keeps when a recolor, or a stroke or gradient edit +/// aimed at the characters, changes them on its labels ([`capture_label_changes`]). Each entry copies one attribute +/// from the first style to the second and returns whether they differed. +const RUN_PAINT: [fn(&CharStyle, &mut CharStyle) -> bool; 8] = [ + |a, b| keep(&a.fill, &mut b.fill), + |a, b| keep(&a.stroke, &mut b.stroke), + |a, b| keep(&a.stroke_width, &mut b.stroke_width), + |a, b| keep(&a.stroke_cap, &mut b.stroke_cap), + |a, b| keep(&a.stroke_join, &mut b.stroke_join), + |a, b| keep(&a.stroke_miter_limit, &mut b.stroke_miter_limit), + |a, b| keep(&a.stroke_dash, &mut b.stroke_dash), + |a, b| keep(&a.stroke_align, &mut b.stroke_align), +]; + +/// Copy `from` to `to`. Returns whether they differed. +fn keep(from: &T, to: &mut T) -> bool { + let differs = from != to; + if differs { + to.clone_from(from); + } + differs +} + +/// The [`RUN_PAINT`] attributes an edit changed on the characters of graph label `now` (`was`: the label before the +/// edit), as the label's style and the indexes of the changed entries. An attribute counts only when every character +/// of the label shows it alike, and the fill only when `fill` is set. A placed gradient is kept without its placement, +/// so it fits each label it lands on. The stroke weight and dashes are kept as the label shows them: the graph draws its +/// labels again without the label's own scale. +fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Vec)> { + let (NodeKind::Text(was), NodeKind::Text(now)) = (&was.kind, &now.kind) else { return None }; + let first_now = &now.runs.first()?.style; + // Changed: some character showed something else before the edit, whichever run it is in. + let changed: Vec = RUN_PAINT + .iter() + .enumerate() + .filter(|(i, copy)| { + (fill || *i != 0) + && now.runs.iter().all(|r| !copy(first_now, &mut r.style.clone())) + && was.runs.iter().any(|r| copy(first_now, &mut r.style.clone())) + }) + .map(|(i, _)| i) + .collect(); + if changed.is_empty() { + return None; + } + let unplaced = crate::cmd::gradient::unplaced; + let mut style = first_now.clone(); + (style.fill, style.stroke) = (unplaced(&style.fill), unplaced(&style.stroke)); + // Character strokes are drawn in text space, scaled by the type's transform. + if let Some(k) = vectorcraft_doc::Scaling::factor(now.xf).filter(|k| k.is_finite() && *k > 1e-12) { + let mut st = style.stroke_layer(); + crate::inspect::scale_layer(&mut st, k); + style.set_stroke_layer(&st); + } + Some((style, changed)) +} + +/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with +/// Legends in Wedges whose legend labels keep no fill of their own? +pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool { + g.kind == GraphKind::Pie + && g.legend + && g.pie_legend_in_wedges + && !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)) +} + +/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them. +pub(crate) fn labels_in(doc: &Document, roots: &[NodeId]) -> Vec { + let mut texts = Vec::new(); + for id in roots { + let Some(root) = doc.node(*id) else { continue }; + root.walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) { + texts.push(n.id); + } + }); + } + // Graph labels are type: without type among `roots`, the graph's labels are not looked up. + if texts.is_empty() { + return texts; + } + let labels = graph_labels(doc); + texts.retain(|id| labels.contains_key(id)); + // Nested roots (a graph and one of its labels) reach a label twice. + texts.sort_unstable(); + texts.dedup(); + texts +} + +/// Keep for every label of their kind what an edit changed on graph labels `ids` (`before`: the document before the +/// edit): with `rows`, the object rows ([`capture_label_rows`]) of the labels whose rows changed, and the character fill +/// and stroke attributes ([`RUN_PAINT`]) that changed on all of a label's characters, on the kind's kept style. The +/// frontmost label of a kind sets what that kind keeps, whatever order `ids` are in. On a graph that colors its legend +/// labels itself ([`picks_legend_fill`]), their fill is kept only when the legend labels among `ids` end up with one +/// fill. Called inside the same edit, so they undo together. Returns whether what a graph keeps changed. +pub(crate) fn capture_label_changes(before: &Document, doc: &mut Document, ids: &[NodeId], rows: bool) -> bool { + if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return false; + } + let labels = graph_labels(doc); + let mut picked: Vec = ids.iter().copied().filter(|id| labels.contains_key(id)).collect(); + // A later label's capture replaces an earlier one's: take them in paint order, each once. + picked.sort_by_cached_key(|id| doc.index_path(*id)); + picked.dedup(); + let picks_fill = |graph: NodeId| doc.node(graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(picks_legend_fill); + // On a pie that colors its legend labels black or white by their wedge, a recolor of the whole pie flips them both + // ways: their fill is kept only when every legend label the edit reached ends up with the same one. + let mut wedge_fills: HashMap> = HashMap::new(); + for id in &picked { + let (Some(&(graph, Role::Legend)), Some(NodeKind::Text(t))) = (labels.get(id), doc.node(*id).map(|n| &n.kind)) else { continue }; + if picks_fill(graph) { + wedge_fills.entry(graph).or_default().extend(t.runs.iter().map(|r| r.style.fill.clone())); + } + } + let one_fill = |graph: NodeId| wedge_fills.get(&graph).is_none_or(|f| f.windows(2).all(|w| w[0] == w[1])); + let mut changed_rows = Vec::new(); + let mut characters = Vec::new(); + for (id, kind) in picked.iter().filter_map(|id| Some((*id, *labels.get(id)?))) { + let (Some(was), Some(now)) = (before.node(id), doc.node(id)) else { continue }; + if rows && label_rows(was) != label_rows(now) { + changed_rows.push(id); + } + let fill = kind.1 != Role::Legend || one_fill(kind.0); + characters.extend(run_paint_change(was, now, fill).map(|change| (kind, change))); + } + let mut changed = capture_label_rows(doc, &changed_rows); + for ((graph, role), (style, entries)) in &characters { + changed |= store_label_style(doc, *graph, *role, &|st| { + for i in entries { + if let Some(copy) = RUN_PAINT.get(*i) { + copy(style, st); + } + } + }); + } + changed +} + /// The graphs and kinds of label among `ids`, each once. fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { if ids.is_empty() { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index bcd6f406ed..a4bcb7e637 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5480,6 +5480,243 @@ fn a_graphic_style_on_a_label_is_kept_for_its_kind() { assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}"); } +/// The character style of the first run of label `text` in group `grp`. +fn label_run_style(s: &Session, id: NodeId, grp: &str, text: &str) -> CharStyle { + let NodeKind::Text(t) = label_node(s, id, grp, text).kind else { panic!("{text} is not type") }; + t.runs[0].style.clone() +} + +/// A recolor of the whole graph (Invert or Complement on the proxy, an Edit Colors command, a color filter or Recolor +/// Artwork) is kept for the characters and the rows of every kind of label when the graph is drawn again, and undo takes +/// it back with its edit. +#[test] +fn recoloring_a_graph_keeps_its_labels_paint_for_every_kind() { + let cases = [ + ("paint.invert", json!({})), + ("paint.complement", json!({})), + ("edit.colors.invert", json!({})), + ("edit.colors.toGrayscale", json!({})), + ("addon.color.filter", json!({"effect": "addon.color.invert"})), + ("recolor.apply", json!({"map": {"#3366cc": "#00ff00", "#ff8000": "#8000ff"}})), + ]; + every_case(cases, |(cmd, params)| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Characters of every label in a color that every case changes, and an orange fill row on the category labels. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff8000"})).unwrap(); + let was = spec(&s, id).label_styles; + let mut p = params.clone(); + if *cmd == "recolor.apply" { + // Recolor Artwork works on the selection. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + } else { + p["ids"] = json!([id.0]); + } + s.execute(cmd, &p).unwrap(); + let hex = |p: &Paint| p.color().map(|c| c.to_hex()).unwrap_or_default(); + let characters = hex(&label_run_style(&s, id, "Axes", "4").fill); + assert_ne!(characters, "#3366cc", "{cmd} recolors the characters"); + let row = label_node(&s, id, "Axes", "Q2").appearance.items[0].paint().clone(); + assert_ne!(hex(&row), "#ff8000", "{cmd} recolors the row"); + let looks = label_looks(&mut s, id); + for t in ["0", "Q1", "2024"] { + assert!(looks.iter().any(|(x, _, fill)| x == t && *fill == characters), "{cmd}: {t}: {looks:?}"); + } + let rows = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&rows, "Q1").0.first().map(|r| r.1.clone()), Some(hex(&row)), "{cmd}: {rows:?}"); + // Undo takes back the recolor and what the graph keeps (the two redraws are undone first). + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(spec(&s, id).label_styles, was, "{cmd}"); + }); +} + +/// A recolor of one label changes what the graph keeps for that kind of label only. +#[test] +fn recoloring_one_label_keeps_its_paint_for_its_kind_only() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("edit.colors.invert", &json!({"ids": [v.0]})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ffffff"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#000000"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "2024" && fill == "#000000"), "{looks:?}"); + let g = spec(&s, id).label_styles; + assert!(g.categories.is_none() && g.legend.is_none(), "{g:?}"); +} + +/// A recolor that leaves a label's first characters as they were and gives the rest the same fill is kept for its kind. +#[test] +fn a_recolor_that_evens_out_a_labels_characters_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let q2 = text_id(&s, id, "Axes", "Q2"); + s.select_tool("type", crate::ViewInfo::default()).unwrap(); + for (start, end, hex) in [(0, 1, "#ff0000"), (1, 2, "#0000ff")] { + s.set_tool_option("textSelection", &json!({"id": q2.0, "start": start, "end": end})); + s.execute("paint.setFill", &json!({"color": hex})).unwrap(); + } + s.select_tool("selection", crate::ViewInfo::default()).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories, None, "part of a label's text is not kept"); + s.execute("select.set", &json!({"ids": [q2.0]})).unwrap(); + s.execute("recolor.apply", &json!({"map": {"#0000ff": "#ff0000"}})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#ff0000"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}"); +} + +/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` on a label's characters are kept +/// for every label of its kind, the gradient without its placement. +#[test] +fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let category = label_run_style(&s, id, "Axes", "Q1"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap(); + s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap(); + assert!(matches!(label_run_style(&s, id, "Axes", "4").fill, Paint::Gradient(_)), "the edit makes a gradient"); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round)); + assert!(matches!(&st.fill, Paint::Gradient(g) if g.geom.is_none()), "{:?}", st.fill); + assert_eq!(label_run_style(&s, id, "Axes", "Q1"), category, "the category labels are left as they were"); +} + +/// `stroke.set`, `stroke.weightUp` and `stroke.weightDown` given the whole graph are kept for every kind of label. +#[test] +fn stroke_weights_set_on_the_whole_graph_are_kept_for_its_labels() { + let cases = [("stroke.set", json!({"weight": 2}), 2.0), ("stroke.weightUp", json!({}), 2.0), ("stroke.weightDown", json!({}), 0.75)]; + every_case(cases, |(cmd, params, weight)| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + let mut p = params.clone(); + p["ids"] = json!([id.0]); + s.execute(cmd, &p).unwrap(); + assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, *weight, "{cmd}"); + label_looks(&mut s, id); + for (grp, t) in [("Axes", "0"), ("Axes", "Q1"), ("Legend", "2024")] { + assert_eq!(label_run_style(&s, id, grp, t).stroke_width, *weight, "{cmd}: {t}"); + } + }); +} + +/// A stroke weight set on a scaled label is kept as the label shows it, since the graph draws its labels unscaled. +#[test] +fn a_stroke_weight_on_a_scaled_label_is_kept_as_it_shows() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3})).unwrap(); + assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, 1.5, "drawn at twice its size"); + label_looks(&mut s, id); + assert_eq!(label_run_style(&s, id, "Axes", "0").stroke_width, 3.0); +} + +/// When the characters of two labels of one kind are recolored differently, the frontmost one sets what the kind +/// keeps, whatever order `ids` are in. +#[test] +fn the_frontmost_label_of_a_kind_sets_what_its_recolor_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Legend labels: the axes tell value and category labels apart by their order, so these are moved instead. + let (a, b) = (text_id(&s, id, "Legend", "2024"), text_id(&s, id, "Legend", "2025")); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap(); + // The label made first goes in front, so neither the order of `ids` nor the order the labels were made in decides. + let (front, back) = if a < b { (a, b) } else { (b, a) }; + s.execute("select.set", &json!({"ids": [front.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + let path = |n: NodeId| s.doc().unwrap().doc.index_path(n); + assert!(path(front) > path(back), "{:?} {:?}", path(front), path(back)); + let front_fill = if front == a { "#00ffff" } else { "#ffff00" }; + s.execute("edit.colors.invert", &json!({"ids": [front.0, back.0]})).unwrap(); + let kept = spec(&s, id).label_styles.legend.unwrap().fill; + assert_eq!(kept.color().map(|c| c.to_hex()).as_deref(), Some(front_fill)); +} + +/// Apply Nearest Swatch and the ink limit of Print Ink Fixes on a label are kept for its kind. +#[test] +fn nearest_swatch_and_ink_limit_on_a_label_are_kept_for_its_kind() { + let cases = [ + ("addon.color.applyNearestSwatch", json!({"library": "web-safe-216"})), + ("addon.color.printFixes", json!({"scope": "selection", "inkLimit": 200})), + ]; + every_case(cases, |(cmd, params)| { + let mut s = Session::new(); + // Print Ink Fixes works on CMYK documents. + s.execute("file.new", &json!({"width": 800, "height": 800, "colorMode": "cmyk"})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "color": {"c": 90, "m": 80, "y": 70, "k": 60}})).unwrap(); + let was = label_run_style(&s, id, "Axes", "4").fill; + let mut p = params.clone(); + p["ids"] = json!([v.0]); + s.execute(cmd, &p).unwrap(); + let now = label_run_style(&s, id, "Axes", "4").fill; + assert_ne!(now, was, "{cmd} changes the fill"); + label_looks(&mut s, id); + assert_eq!(label_run_style(&s, id, "Axes", "0").fill, now, "{cmd}"); + }); +} + +/// Swap Fill and Stroke and Default Fill and Stroke on a label are kept for its kind. +#[test] +fn swap_and_default_fill_and_stroke_on_a_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.swap", &json!({"ids": [v.0]})).unwrap(); + let swapped = label_run_style(&s, id, "Axes", "4"); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((&st.fill, &st.stroke), (&swapped.fill, &swapped.stroke)); + assert_eq!(st.fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000")); + s.execute("paint.default", &json!({"ids": [text_id(&s, id, "Axes", "4").0]})).unwrap(); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((st.fill.color().map(|c| c.to_hex()).as_deref(), st.stroke.is_none()), (Some("#000000"), true)); +} + +/// On a pie with Legends in Wedges, a recolor of the whole graph leaves the legend labels to the graph's black or white. +#[test] +fn a_recolor_leaves_wedge_legend_labels_their_contrast() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nP1,1,2"); + s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); + s.execute("edit.colors.invert", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(spec(&s, id).label_styles.legend, None); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap(); + // The first series' black wedge is white now, so its label is black. + let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") }; + assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()).as_deref(), Some("#000000")); + // A recolor of one legend label gives the kind that label's fill. + s.execute("edit.colors.invert", &json!({"ids": [text_id(&s, id, "Legend", "a").0]})).unwrap(); + let kept = spec(&s, id).label_styles.legend.map(|st| st.fill); + assert_eq!(kept.and_then(|f| f.color()).map(|c| c.to_hex()).as_deref(), Some("#ffffff")); +} + #[test] fn pie_names_and_wedge_labels_keep_their_style() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c7159d563a..60c5180f40 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight, and on a graph label the swapped paints of its characters for every label of its kind (exceptions: docs/mcp.md)", has_doc, swap ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and on a graph label the black fill and no stroke of its characters for every label of its kind (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md) → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -925,7 +925,8 @@ fn swap(s: &mut Session, p: &Value) -> Result { } // The swap writes the paint of the topmost fill and stroke, not their opacity. let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false); - Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) + let labels = super::graph::capture_label_changes(&before, d, &ids, false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -954,7 +955,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { } } let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false); - Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) + let labels = super::graph::capture_label_changes(&before, d, &ids, false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels)) })?; } super::newart::reset(s); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index db0c0ac367..5d7e1ab633 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c5e5ba2a5c..14282c5112 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, and on its characters the stroke options they take → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Stroke Weight Up", [], Some("Cmd+Alt+."), - "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}", + "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke, and on a graph label the new weight of its characters' stroke or its stroke row for every label of its kind (docs/mcp.md) → {objects}", has_doc, |s, p| stroke_step(s, p, true) ), diff --git a/docs/mcp.md b/docs/mcp.md index 992a3078fa..f8049365ef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -807,8 +807,17 @@ commands store it too when they are given a series group, the graph or a group t leaves as it was keeps what the graph stores. When one command changes several paths of a series, the frontmost of them sets what the graph stores. A gradient is stored with its placement, and every path of the series takes that placement when the graph is drawn again. The graph does not store a stroke's dashes, caps, joins, arrowheads, width -profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again -with the paint they had. +profile or brush. On a graph label, given the label or something that holds it, these commands, `paint.swap` and +`paint.default` store what they change for every label of its kind: the label's object rows, as the `appearance.*` +commands store them, and the fill, stroke, stroke weight, caps, joins, miter limit, dashes and stroke alignment of its +characters when all of its characters show them alike. A gradient on the characters is stored without its placement +(its vector, aspect ratio, focal point and freeform points), and each label fits it to its own box when the graph is +drawn again, so `paint.setGradientGeom` and the `paint.freeform.*` edits do not store the placement they set there. A +stroke weight and dashes are stored as the label shows them, also on a label that was scaled. When the characters of +labels of one kind change differently, the frontmost label sets what the kind keeps. On a pie with Legends in Wedges +whose legend labels have no fill of their own, the legend labels' fill is stored only when every legend label the +command reaches ends up with the same fill. Otherwise they are drawn again in black, or white on a dark wedge. These commands do not store the colors they give a graph's axis lines, ticks and radar rings, +which are drawn again with their default paint. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8f87c83072..8a003daee9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1088,7 +1088,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph, Appearance, Transparency or Graphic Styles controls or the Eyedropper, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. From b097c2a957b4c53f8d37b56f7e78d66c495d2aa4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:30:17 -0800 Subject: [PATCH 05/35] .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 c88da287e72f40a2b2f0e67701c9e7a52f358c46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:34:27 -0800 Subject: [PATCH 06/35] Graphs: the label gradient test places the gradient before the redraw drops its placement The test now sets the edited label's gradient vector and aspect with paint.setGradientGeom and checks that the gradient is placed before the graph redraws, so the later check that the kept gradient has no placement fails if the redraw keeps it. --- crates/engine/src/cmd/graph/tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 1f128009f8..ea47c82112 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5692,8 +5692,9 @@ fn a_recolor_that_evens_out_a_labels_characters_is_kept_for_its_kind() { assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}"); } -/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` on a label's characters are kept -/// for every label of its kind, the gradient without its placement. +/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` and placed with +/// `paint.setGradientGeom` on a label's characters are kept for every label of its kind, the gradient without its +/// placement. #[test] fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() { let mut s = Session::new(); @@ -5704,7 +5705,10 @@ fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() { s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap(); s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap(); - assert!(matches!(label_run_style(&s, id, "Axes", "4").fill, Paint::Gradient(_)), "the edit makes a gradient"); + let placement = json!({"ids": [v.0], "stroke": false, "start": [100, 100], "end": [200, 100], "aspect": 50}); + s.execute("paint.setGradientGeom", &placement).unwrap(); + let fill = label_run_style(&s, id, "Axes", "4").fill; + assert!(matches!(&fill, Paint::Gradient(g) if g.geom.is_some()), "the edited label's gradient is placed: {fill:?}"); label_looks(&mut s, id); let st = label_run_style(&s, id, "Axes", "0"); assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round)); From f3d04df7b8eb25d1ace5d05ff380838c8a3bb4a3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:24:24 -0800 Subject: [PATCH 07/35] i18n: Dutch has a row for the messages the untranslated report lists The Dutch catalog lacked a row for 1,813 of the status and error messages that the untranslated report finds in the sources, so the status bar showed those messages in English. - nl.tsv gains 1,813 `@msg` rows: the errors of commands and their parameter checks, scripts, file import and export, printing and plug-ins. The translations are VectorCraft's own, written from the English meaning with the catalog's conventions and its existing feature names. Placeholders, parameter names, command ids and literal values stay as the English writes them. - The untranslated report lists 41 Dutch messages after the change: the composite font errors, the justification spacing error and the `connect` end error, which open #7211, #7116 and #7330 translate, and three unknown-parameter and range errors that open #7353 rewrites so that a catalog row can match them. Menu strings, `tl!` literals and undo labels are unchanged. - The row for `{k} has control characters{_1}` does not match the message for a multi-line File Info field, where the last placeholder is empty. - docs/localization-parity.md's Dutch row and a line in docs/target-app-parity.md give the new counts. --- crates/ui-egui/src/i18n/nl.tsv | 1818 ++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 1820 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 7ce152334e..1398564f5a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5557,6 +5557,1824 @@ imageTrace Outline Contour @msg missing panel layout ontbrekende paneelindeling @msg unsupported native docking operation niet-ondersteunde systeemeigen dokbewerking @msg this kind of brush has no options dialog yet dit soort penseel heeft nog geen dialoogvenster met opties + +# Status and error messages: the script engine's own errors, command parameters, file readers and +# writers, dialogs, panels and add-ons. Names in backticks, command ids, double-quoted values and +# bare true, false and null stay as the English writes them. +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 'caller', 'callee' en 'arguments' kunnen niet worden gebruikt bij functies in strikte modus +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Een profiel ondersteunt één effect Draaien. Bewerk in plaats daarvan de opties van het bestaande effect. +@msg Add Points at Crossings: a path went missing Punten toevoegen bij kruisingen: er is een pad verdwenen +@msg Alignment must be one of the choices De uitlijning moet een van de keuzes zijn +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} is aangeroepen op een waarde die geen Boolean is +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB CMYK-afbeeldingen waarvan de samples niet in CMYK konden worden gelezen, zijn in RGB geïmporteerd +@msg CSS options: {e} CSS-opties: {e} +@msg CSV has a header but no data rows De CSV heeft een koptekst maar geen gegevensrijen +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark De CSV bevat NUL-bytes: sla het bestand op als UTF-8, of als UTF-16 met een byte-order mark +@msg CSV is empty De CSV is leeg +@msg CSV is larger than {MAX_CSV_BYTES} bytes De CSV is groter dan {MAX_CSV_BYTES} bytes +@msg CSV is not UTF-16 De CSV is geen UTF-16 +@msg CSV is not UTF-8 De CSV is geen UTF-8 +@msg CSV would be larger than {MAX_CSV_BYTES} bytes De CSV zou groter worden dan {MAX_CSV_BYTES} bytes +@msg Cannot write to the location of “{name}”; choose where to save it Kan niet schrijven naar de locatie van „{name}”; kies waar je het wilt opslaan +@msg Choose original artwork, not a dimension's generated paths Kies de oorspronkelijke illustratie, niet de gegenereerde paden van een maatlijn +@msg Contour: select paths or shapes Omtrek: selecteer paden of vormen +@msg Contour: the Offset Path effect is not available Omtrek: het effect Pad verschuiven is niet beschikbaar +@msg Create needs fills, strokes or both Maken heeft vullingen, contouren of beide nodig +@msg DCS files can only be placed linked: use File › Place with Link on DCS-bestanden kunnen alleen gekoppeld worden geplaatst: gebruik Bestand › Plaatsen met Koppeling ingeschakeld +@msg DXF options: {e} DXF-opties: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} De DXF-schaal moet een positief getal tot en met {_1} zijn, niet {scale} +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object Een methode van Date.prototype is aangeroepen op een waarde die geen Date-object is +@msg Dimension is locked or hidden De maatlijn is vergrendeld of verborgen +@msg Dimension mode does not match its source De modus van de maatlijn komt niet overeen met de bron ervan +@msg Draw Inside needs exactly one selected path, compound path or text Binnen tekenen heeft precies één geselecteerd pad, samengesteld pad of tekstobject nodig +@msg EPS Save writes all artwork: use document.export for selected-only EPS Opslaan als EPS schrijft alle illustraties: gebruik document.export voor een EPS met alleen de selectie +@msg EPS flattener: {e} EPS-afvlakker: {e} +@msg EPS options: {e} EPS-opties: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString is aangeroepen op een waarde die geen object is +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Exporteren voor schermen schrijft png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai of vectorcraft +@msg Flare click coordinates must be finite numbers De klikcoördinaten van de flare moeten eindige getallen zijn +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString is aangeroepen op een waarde die geen functie is +@msg HTML Text applies to object slices of type only HTML-tekst geldt alleen voor objectslices van tekst +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Objecten identificeren op staat op XML-id: de naam moet een geldige XML-id zijn (begin met een letter, onderstrepingsteken of dubbele punt; geen spaties) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) Afbeelding traceren is mislukt: interne fout: de thread ervan is gestopt (meld deze bug alsjeblieft) +@msg Image Trace failed: {e} Afbeelding traceren is mislukt: {e} +@msg Image Trace finished, but its document was closed Afbeelding traceren is voltooid, maar het document ervan is gesloten +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Afbeelding traceren is voltooid, maar de afbeelding of de instellingen ervan zijn intussen gewijzigd: traceer de afbeelding opnieuw +@msg Import SVG Filter reads SVG files, not SVGZ SVG-filter importeren leest SVG-bestanden, geen SVGZ +@msg Invalid dimension offset Ongeldige verschuiving van de maatlijn +@msg Invalid point Ongeldig punt +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (op positie {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify kan geen structuur schrijven die zichzelf bevat +@msg Join: a subpath went missing Verbinden: er is een subpad verdwenen +@msg Line Gap must be a number De lijnafstand moet een getal zijn +@msg Lock Workspace is not available in VectorCraft Werkruimte vergrendelen is niet beschikbaar in VectorCraft +@msg Move Panel to New Window is not available: floating panels stay in the main window Paneel naar nieuw venster verplaatsen is niet beschikbaar: zwevende panelen blijven in het hoofdvenster +@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString heeft een Namespace nodig +@msg New Document profiles need the preferences folder, which this run doesn't have Profielen voor nieuwe documenten hebben de voorkeurenmap nodig, die deze sessie niet heeft +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} is aangeroepen op een waarde die geen getal is +@msg Object.create's prototype must be an object or null Het prototype van Object.create moet een object of null zijn +@msg Object.{function} was given a value that is not an object Object.{function} kreeg een waarde die geen object is +@msg Offset Path: the path is too complex to offset Pad verschuiven: het pad is te complex om te verschuiven +@msg Offset Path: {e} Pad verschuiven: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported De PDF-inhoud is onvolledig of overschrijdt de limieten van de lezer; er is geen document geïmporteerd +@msg PDF flattener: {e} PDF-afvlakker: {e} +@msg PDF options: {e} PDF-opties: {e} +@msg QName.prototype.toString needs a QName QName.prototype.toString heeft een QName nodig +@msg Recolor Artwork is no longer open Artwork herkleuren is niet meer geopend +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} is aangeroepen op een waarde die geen RegExp-object is +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 De SVG is geen UTF-8, UTF-16 of ISO 8859-1 +@msg Save writes one artboard: name one, or export several with document.export Opslaan schrijft één tekengebied: noem er een, of exporteer er meerdere met document.export +@msg Select a dimension first Selecteer eerst een maatlijn +@msg Select dimensions to update Selecteer maatlijnen om bij te werken +@msg Select only dimensions Selecteer alleen maatlijnen +@msg Select two or more objects, or a group Selecteer twee of meer objecten, of een groep +@msg Shaper needs 1–64 source paths Shaper heeft 1–64 bronpaden nodig +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Sjabloonbruggen: de geselecteerde illustratie is te groot of te gedetailleerd om te overbruggen +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString is aangeroepen op een waarde die geen string is +@msg Switch x/y is for scatter graphs x/y omwisselen is bedoeld voor spreidingsgrafieken +@msg Text options: {e} Tekstopties: {e} +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas De koppeling zit in een symbool, grafiekontwerp, patroon of dekkingmasker en heeft geen plaats op het canvas +@msg The {name} profile could not be read, so the new document has the default settings: {reason} Het profiel {name} kon niet worden gelezen, dus heeft het nieuwe document de standaardinstellingen: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Overbrugging: meer dan {_1} paren objecten liggen dicht genoeg bij elkaar om te overbruggen, of te veel objecten delen een horizontaal bereik; overbrug minder objecten tegelijk +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Wiedkader en wiedlijnen: er past geen wiedlijn buiten de illustratie en het wiedkader is uitgeschakeld +@msg \\ at end of pattern \\ aan het einde van het patroon +@msg `alignOn` cannot be empty `alignOn` mag niet leeg zijn +@msg `alignOn` must be a string `alignOn` moet een string zijn +@msg `align` is start, center or end `align` is start, center of end +@msg `align` must be a string `align` moet een string zijn +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` moet een array van paren [subpad, anker] zijn +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` moet ankerindexen bevatten (0, 1, 2…), niet {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` moet ankerindexen bevatten (0, 1, 2…), niet {v} +@msg `answer` for this {kind} dialog must be one of: {_1} `answer` voor dit dialoogvenster {kind} moet een van de volgende zijn: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers `answers` heeft meer dan {MAX_ANSWERS} antwoorden +@msg `answers` is larger than 1 MiB `answers` is groter dan 1 MiB +@msg `answers` must be a list of answers `answers` moet een lijst met antwoorden zijn +@msg `artboard` must be the index of an artboard of the document `artboard` moet de index van een tekengebied van het document zijn +@msg `artboardsAt` must be [x, y] `artboardsAt` moet [x, y] zijn +@msg `artboards` must be a list of artboard indices `artboards` moet een lijst met tekengebiedindexen zijn +@msg `artboards` names an artboard the document does not have `artboards` noemt een tekengebied dat het document niet heeft +@msg `artboards` names an artboard twice `artboards` noemt een tekengebied twee keer +@msg `asset` is the asset id (see assets.list) `asset` is de asset-id (zie assets.list) +@msg `at` must be [x, y] (got {at}) `at` moet [x, y] zijn (kreeg {at}) +@msg `at` must be a byte offset `at` moet een bytepositie zijn +@msg `at` must be a point [x, y] `at` moet een punt [x, y] zijn +@msg `autoLeading` must be a number `autoLeading` moet een getal zijn +@msg `background` must be "", "matte" or #rrggbb `background` moet "", "matte" of #rrggbb zijn +@msg `beside` needs `side` `beside` heeft `side` nodig +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` moet "none", "standard" of "forced" zijn +@msg `center` must be [x, y] `center` moet [x, y] zijn +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` moet "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" of "icfBottom" zijn +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` moet "default", "leftToRight" of "rightToLeft" zijn +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke` vereist `stroke: false`, `ids: []` en geen `stop` +@msg `clear` must be a list of type object ids, not {v} `clear` moet een lijst met id’s van tekstobjecten zijn, niet {v} +@msg `clear` must list type objects; {_1} is not one `clear` moet tekstobjecten bevatten; {_1} is er geen +@msg `clear` must not list `id` `clear` mag `id` niet bevatten +@msg `code` must be a whole number `code` moet een geheel getal zijn +@msg `colorMode` must be one of rgb, cmyk `colorMode` moet een van rgb, cmyk zijn +@msg `color` needs a `rule` to make the theme from `color` heeft een `rule` nodig om het thema mee te maken +@msg `colors` does not apply to the {name} reduction `colors` geldt niet voor de reductie {name} +@msg `colors` is empty `colors` is leeg +@msg `colors` must be a whole number from 2 to 256 `colors` moet een geheel getal van 2 tot 256 zijn +@msg `colors` must be at least 9 for the uniform reduction `colors` moet minstens 9 zijn voor de uniforme reductie +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` moet tussen 1 en {MAX_THEME_COLORS} liggen (kreeg {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` is vertical, uniform, repeating of sliding +@msg `columns` must be a whole number 1..50 `columns` moet een geheel getal 1..50 zijn +@msg `commands` must not be empty `commands` mag niet leeg zijn +@msg `contents` goes with `to` `contents` hoort bij `to` +@msg `corners` must be sharp, round or square, not `{other}` `corners` moet sharp, round of square zijn, niet `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` moet ankerindexen bevatten (0, 1, 2…), niet {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` moet een geheel getal van 0 tot {MAX_COUNT} zijn +@msg `count` must be a whole number from 1 to 100 `count` moet een geheel getal van 1 tot 100 zijn +@msg `dataBase64` is larger than the module limit `dataBase64` is groter dan de modulelimiet +@msg `dataBase64` is not valid base64 `dataBase64` is geen geldige base64 +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` kan niet worden gecombineerd met `ids`, `reverse` of `append` +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` en `childLimit` beperken de samenvatting: geef `summary: true` op +@msg `design` is a design name, or null for plain columns `design` is de naam van een ontwerp, of null voor gewone kolommen +@msg `design` is a design name, or null for the default marker `design` is de naam van een ontwerp, of null voor de standaardmarkering +@msg `destinationFolder` is required when destination is `folder` `destinationFolder` is vereist wanneer de bestemming `folder` is +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` moet `none`, `saveAndClose` of `folder` zijn +@msg `diameter` must be from 0.1 to 2500 pt `diameter` moet van 0,1 tot 2500 pt zijn +@msg `digits` must be one of {_1} `digits` moet een van {_1} zijn +@msg `dimImages` is a percentage or false `dimImages` is een percentage of false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` moet "auto", "leftToRight" of "rightToLeft" zijn +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` moet een getal van 0 tot 100 zijn +@msg `each` is the value one design stands for, more than 0 `each` is de waarde waar één ontwerp voor staat, meer dan 0 +@msg `end` must be "start" or "end" `end` moet "start" of "end" zijn +@msg `ends` applies to joining exactly two paths `ends` geldt voor het verbinden van precies twee paden +@msg `event` must be one of {_1} `event` moet een van {_1} zijn +@msg `every` must be "all", "odd" or "even", not {v} `every` moet "all", "odd" of "even" zijn, niet {v} +@msg `expand` must be true or false `expand` moet true of false zijn +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` moet van {lo} tot {hi} pt zijn +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` moet 1 tot 1000 paden van lettertypebestanden bevatten die de laatste zoekopdracht van text.findFontFiles heeft gevonden +@msg `focal` and `radius` cannot be used together `focal` en `radius` kunnen niet samen worden gebruikt +@msg `focal` must be an array of [subpath, anchor] pairs `focal` moet een array van paren [subpad, anker] zijn +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` noemt [{si}, {ai}], en dat is geen anker van het pad +@msg `folder` must be an absolute folder path `folder` moet een absoluut mappad zijn +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` moet een object met astags en waarden zijn, zoals {"wght"} +@msg `format` must be a string `format` moet een string zijn +@msg `format` must be one of {_1} `format` moet een van {_1} zijn +@msg `fractions` is chop or scale `fractions` is chop of scale +@msg `from` and `to` must differ `from` en `to` moeten verschillen +@msg `from` has an empty frame `from` heeft een leeg kader +@msg `from` must be a non-negative integer `from` moet een niet-negatief geheel getal zijn +@msg `from` must be area type or type on a path `from` moet vlaktekst of tekst op een pad zijn +@msg `gid` must be a glyph id, not {v} `gid` moet een glyph-id zijn, niet {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` hoort bij één teken, niet bij een tekenstijl (text.setGlyphAlternate stelt het in) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` accepteert alleen null, dat een glyphkeuze wegneemt (text.setGlyphAlternate maakt er een) +@msg `group` must be one of {_1} `group` moet een van {_1} zijn +@msg `group` must be the Objects on Path group of `member` and `target` `group` moet de groep Objecten op pad van `member` en `target` zijn +@msg `guide` must be a guide index (guide.list) `guide` moet een hulplijnindex zijn (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` moet default, left, center of right zijn +@msg `id` is an object id, not {v} `id` is een object-id, niet {v} +@msg `id` must be a node id `id` moet een knooppunt-id zijn +@msg `id` must be an object id, not {v} `id` moet een object-id zijn, niet {v} +@msg `ids` must name Objects on Path groups or objects in them `ids` moet groepen Objecten op pad of objecten daarin noemen +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` accepteert maximaal {MAX_INSPECT_IDS} id’s +@msg `imageMap` must be none, rectangle or polygon `imageMap` moet none, rectangle of polygon zijn +@msg `includeArtboardFill` must be true or false `includeArtboardFill` moet true of false zijn +@msg `includeSubdirs` must be true or false `includeSubdirs` moet true of false zijn +@msg `index` must be a point index `index` moet een puntindex zijn +@msg `index` must be a whole number or null `index` moet een geheel getal of null zijn +@msg `indices` must be a non-empty array of item indices `indices` moet een niet-lege array van itemindexen zijn +@msg `item` needs `index` `item` heeft `index` nodig +@msg `items` is a list of {kind, item} `items` is een lijst van {kind, item} +@msg `items` is empty `items` is leeg +@msg `items` must be a list of {id, points} `items` moet een lijst van {id, points} zijn +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` moet een lijst van {{id, anchors?}} zijn, niet {v} +@msg `items` must be an array of {id, anchors} `items` moet een array van {id, anchors} zijn +@msg `items` must be an array of {{id, corners?}}, not {v} `items` moet een array van {{id, corners?}} zijn, niet {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` moet "none", "short", "medium", "long" of "stylistic" zijn +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` hoort bij het eerste teken van een regel, niet bij een tekenstijl (text.setRangeStyle stelt het in) +@msg `kind` is one of {_1} `kind` is een van {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` moet "round", "invertedRound" of "chamfer" zijn, niet {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` moet `text`, `visibility`, `image` of `graph` zijn +@msg `kind` must be a string `kind` moet een string zijn +@msg `kind` must be image, noImage or htmlText `kind` moet image, noImage of htmlText zijn +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` moet "pushInFirst", "pushOutFirst" of "pushOutOnly" zijn +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` moet "none", "hard" of "soft" zijn +@msg `label` must be a non-empty name of at most 200 characters `label` moet een niet-lege naam van maximaal 200 tekens zijn +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` moet een taalnaam of -tag zijn, zoals "Turkish" of "tr" +@msg `leader` must be a string `leader` moet een string zijn +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` moet "romanBaseline" of "emBoxTop" zijn +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` moet van 0,1 tot 5000 zijn, of null voor Automatisch +@msg `length` must be a number of at most 1000000 pt either way `length` moet een getal van maximaal 1000000 pt in beide richtingen zijn +@msg `line` must be true or false `line` moet true of false zijn +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` moet 1 tot {MAX_SUGGESTIONS} zijn +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) mag niet groter zijn dan `maxAngle` ({max_angle}°) +@msg `mode` must be "handles", "straight" or "smooth" `mode` moet "handles", "straight" of "smooth" zijn +@msg `mode` must be "straight", "bezier" or "arc" `mode` moet "straight", "bezier" of "arc" zijn +@msg `mode` must be one of {_1} `mode` moet een van {_1} zijn +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` moet "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" of "lineEndHalf" zijn +@msg `name` must be a name that is not empty `name` moet een naam zijn die niet leeg is +@msg `name` must not be empty `name` mag niet leeg zijn +@msg `names` is a list of preset names `names` is een lijst met namen van voorinstellingen +@msg `names` lists preset names `names` bevat namen van voorinstellingen +@msg `names` must be a list of names `names` moet een lijst met namen zijn +@msg `names` must be a list of swatch names `names` moet een lijst met staalnamen zijn +@msg `names` must be an array of style names `names` moet een array van stijlnamen zijn +@msg `new` is a design name, or true `new` is de naam van een ontwerp, of true +@msg `origin` must be [x, y] in points `origin` moet [x, y] in punten zijn +@msg `path` must be a folder `path` moet een map zijn +@msg `path` must be a path object `path` moet een padobject zijn +@msg `picks` must be an array (got {v}) `picks` moet een array zijn (kreeg {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` moet 1 tot {MAX_PICKS} keuzes bevatten (kreeg {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot` moet een geheel getal van 0 tot 8 zijn +@msg `points` must be a list of [x, y] points `points` moet een lijst met punten [x, y] zijn +@msg `points` must be a list of point indexes `points` moet een lijst met puntindexen zijn +@msg `points` must be an array of point indices `points` moet een array van puntindexen zijn +@msg `points` must be point indexes `points` moet uit puntindexen bestaan +@msg `points` must hold from 2 to 100000 points `points` moet 2 tot 100000 punten bevatten +@msg `port` must be "in" or "out" `port` moet "in" of "out" zijn +@msg `position` is above or below `position` is above of below +@msg `preset` must be a string `preset` moet een string zijn +@msg `radius` must be 0 or more `radius` moet 0 of meer zijn +@msg `ratio` must be from 0.1 to 0.5 `ratio` moet van 0,1 tot 0,5 zijn +@msg `rect` must be [x, y, width, height] `rect` moet [x, y, breedte, hoogte] zijn +@msg `rect` must be [x, y, width, height] with a positive size `rect` moet [x, y, breedte, hoogte] met een positieve grootte zijn +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` moet [x0, y0, x1, y1] zijn (kreeg {r}) +@msg `relation` must be overlap, inside or outside `relation` moet overlap, inside of outside zijn +@msg `replace` must be a string `replace` moet een string zijn +@msg `reversed` must be true or false `reversed` moet true of false zijn +@msg `rotate` must be a finite number `rotate` moet een eindig getal zijn +@msg `rows` must be lists of numbers or nulls, not {r} `rows` moet uit lijsten met getallen of null-waarden bestaan, niet {r} +@msg `rule` must be nonZero or evenOdd `rule` moet nonZero of evenOdd zijn +@msg `scale` must be a number from 0.001 to 1000 `scale` moet een getal van 0,001 tot 1000 zijn +@msg `scale` must be a number in (0, 100] `scale` moet een getal in (0, 100] zijn +@msg `scope.{key}` must be a non-empty array `scope.{key}` moet een niet-lege array zijn +@msg `scope` must be "document", {artboards} or {layers} `scope` moet "document", {artboards} of {layers} zijn +@msg `scope` must be "selection" or "document", not {other} `scope` moet "selection" of "document" zijn, niet {other} +@msg `script` must be a string `script` moet een string zijn +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` is een niet-lege lijst met reeksindexen +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` heeft een `type` of een `valueAxis` nodig +@msg `shift` must be a number of points `shift` moet een aantal punten zijn +@msg `showCenter` must be true or false `showCenter` moet true of false zijn +@msg `side` must be "left", "right", "top" or "bottom" `side` moet "left", "right", "top" of "bottom" zijn +@msg `side` places the artboard: leave out x, y, width and height `side` plaatst het tekengebied: laat x, y, width en height weg +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` moet "fullJustify", "alignLeft", "alignCenter" of "alignRight" zijn +@msg `size` is a percentage of the base text's size, 1 to 100 `size` is een percentage van de grootte van de basistekst, 1 tot 100 +@msg `source` and `target` are the same object `source` en `target` zijn hetzelfde object +@msg `source` is the only path in series group `target` `source` is het enige pad in reeksgroep `target` +@msg `source` must be `folder` or `datasets` `source` moet `folder` of `datasets` zijn +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` levert meer dan {MAX_COUNT} lijnen over het kader op +@msg `spacing` must be a number `spacing` moet een getal zijn +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` moet een aantal punten zijn, 0 of meer, of "auto" +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` moet meer dan 0 en maximaal {MAX_DISTANCE} pt zijn +@msg `start` must be a finite number `start` moet een eindig getal zijn +@msg `starts` must be an array of anchor indices (or nulls) `starts` moet een array van ankerindexen (of null-waarden) zijn +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` moet linked, embedded, missing of modified zijn, of een lijst daarvan +@msg `stop` must be a stop index `stop` moet een stopindex zijn +@msg `story` styles the whole story: give no `start` or `end` `story` maakt het hele artikel op: geef geen `start` of `end` +@msg `story` takes the whole story: give no `start` or `end` `story` neemt het hele artikel: geef geen `start` of `end` +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` geldt voor contouren (stroke: true) +@msg `style` must be roman or japanese `style` moet roman of japanese zijn +@msg `subpaths` must be two integer indices `subpaths` moet uit twee gehele indexen bestaan +@msg `symbol` must be a symbol name `symbol` moet een symboolnaam zijn +@msg `target` must be "object" or "contents", not {v} `target` moet "object" of "contents" zijn, niet {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target` moet een object zijn uit dezelfde groep Objecten op pad als `member` +@msg `tint` applies to global and spot colours `tint` geldt voor globale kleuren en steunkleuren +@msg `tint` goes with a global or spot `swatch` `tint` hoort bij een `swatch` met een globale kleur of steunkleur +@msg `tint` must be a number (%) `tint` moet een getal zijn (%) +@msg `tint` needs a global or spot colour `tint` heeft een globale kleur of steunkleur nodig +@msg `to` is already threaded `to` is al gekoppeld +@msg `to` is the design's new name `to` is de nieuwe naam van het ontwerp +@msg `to` must be [x, y] `to` moet [x, y] zijn +@msg `to` must be a closed path, area type or type on a path other than `from` `to` moet een gesloten pad, vlaktekst of tekst op een pad zijn, anders dan `from` +@msg `to` must be a whole number `to` moet een geheel getal zijn +@msg `to` takes the rows of one object's stack, not aligned rows `to` accepteert de rijen van de stapel van één object, geen uitgelijnde rijen +@msg `tolerance` must be from 0 to 100 pt `tolerance` moet van 0 tot 100 pt zijn +@msg `useLab` must be true or false `useLab` moet true of false zijn +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` moet default, top, middle, baseline of bottom zijn +@msg `values` must be a list `values` moet een lijst zijn +@msg `values` must be an object `values` moet een object zijn +@msg `values` must name hex, rgb, cmyk, lab or gray `values` moet hex, rgb, cmyk, lab of gray noemen +@msg `values` names a kind twice `values` noemt een soort twee keer +@msg `version` must be 0 or {_1} `version` moet 0 of {_1} zijn +@msg `version` must be 0 or {newest} `version` moet 0 of {newest} zijn +@msg `warichuAlign` must be one of {_1} `warichuAlign` moet een van {_1} zijn +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` moet een geheel getal van {_1} tot {_2} zijn +@msg `weight` must be from 0.01 to 1000 pt `weight` moet van 0,01 tot 1000 pt zijn +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` moet een lijst met {_1} getallen zijn, één per rij (kreeg {v}) +@msg `xml` must be a string `xml` moet een string zijn +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`, item {n}, geeft de alinea op byte {start} van {_1} andere inspringingen dan een eerder item +@msg `{KEY}` entry {n} must be an object `{KEY}`, item {n}, moet een object zijn +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`, item {n}, stelt noch `leftIndent` noch `firstLineIndent` in +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`, item {n}: `id` moet een object-id zijn +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`, item {n}: `start` moet een bytepositie zijn +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`, item {n}: `{k}` moet een getal van -{MAX_PARA} tot {MAX_PARA} pt zijn +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`, item {n}: er begint geen alinea van {_1} op byte {start} +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`, item {n}: onbekende sleutel `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`, item {n}: {_1} is geen tekstobject +@msg `{KEY}` is empty `{KEY}` is leeg +@msg `{KEY}` must be a list `{KEY}` moet een lijst zijn +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` accepteert maximaal {MAX_PARAGRAPH_INDENTS} items (kreeg {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` accepteert geen `{other}` ernaast +@msg `{_1}` can't be edited or removed `{_1}` kan niet worden bewerkt of verwijderd +@msg `{_1}` is built in and always listed `{_1}` is ingebouwd en staat altijd in de lijst +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` is geen lettertypebestand dat de laatste zoekopdracht van text.findFontFiles heeft gevonden +@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` zou door het pakket worden overschreven: kies een andere map of naam +@msg `{base}` is larger than {_1} MB `{base}` is groter dan {_1} MB +@msg `{base}` is not in the DCS file's folder `{base}` staat niet in de map van het DCS-bestand +@msg `{file}` has no art to place `{file}` heeft geen illustratie om te plaatsen +@msg `{file}` is over {_1} MB `{file}` is groter dan {_1} MB +@msg `{f}` belongs to a VectorCraft that is running `{f}` hoort bij een VectorCraft die actief is +@msg `{f}` is the copy of a document open now `{f}` is de kopie van een document dat nu geopend is +@msg `{id}` is no writable format (see document.formats) `{id}` is geen schrijfbare indeling (zie document.formats) +@msg `{id}` isn't a loaded library `{id}` is geen geladen bibliotheek +@msg `{key}` has no colours `{key}` heeft geen kleuren +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` bevat meer dan {MAX_EDIT_WORDS} woorden +@msg `{key}` is a design name `{key}` is de naam van een ontwerp +@msg `{key}` is a design name or a list of them `{key}` is de naam van een ontwerp of een lijst daarvan +@msg `{key}` is longer than {MAX_NAME} characters `{key}` is langer dan {MAX_NAME} tekens +@msg `{key}` is out of range `{key}` valt buiten het bereik +@msg `{key}` must be 0 or more `{key}` moet 0 of meer zijn +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` moet [min, desired, max] of {{min?, desired?, max?}} zijn, in procenten +@msg `{key}` must be [x, y] numbers `{key}` moet uit getallen [x, y] bestaan +@msg `{key}` must be a list of words `{key}` moet een lijst met woorden zijn +@msg `{key}` must be a list, not {v} `{key}` moet een lijst zijn, niet {v} +@msg `{key}` must be a non-negative integer `{key}` moet een niet-negatief geheel getal zijn +@msg `{key}` must be a number or null, not {v} `{key}` moet een getal of null zijn, niet {v} +@msg `{key}` must be a number {_1}..{_2} `{key}` moet een getal {_1}..{_2} zijn +@msg `{key}` must be a whole number of 0 or more `{key}` moet een geheel getal van 0 of meer zijn +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` moet een geheel getal van minstens 0 zijn, niet {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}` moet de index van een vormgevingsitem (verfvolgorde) of null zijn +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` moet een array van asset-id’s zijn (zie assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` moet van 0 tot 180 graden zijn, niet {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` moet van 0 tot {MAX_DISTANCE} pt zijn +@msg `{key}` must be from 0.01 to 1000 pt `{key}` moet van 0,01 tot 1000 pt zijn +@msg `{key}` must be from {_1} to {shift_max} `{key}` moet van {_1} tot {shift_max} zijn +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` moet van {lo} tot {hi} zijn, niet {v} +@msg `{key}` must be one of auto, always, never `{key}` moet een van auto, always, never zijn +@msg `{key}` must be one of miter, round, bevel `{key}` moet een van miter, round, bevel zijn +@msg `{key}` must be one word of at most {_1} characters `{key}` moet één woord van maximaal {_1} tekens zijn +@msg `{key}` must hold single words of at most {_1} characters `{key}` moet losse woorden van maximaal {_1} tekens bevatten +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` moet een object noemen dat aan het pad van een groep Objecten op pad is gekoppeld +@msg `{key}` names an object that is hidden or locked `{key}` noemt een object dat verborgen of vergrendeld is +@msg `{key}`: not a colour: {v} `{key}`: geen kleur: {v} +@msg `{k}.family` is required `{k}.family` is vereist +@msg `{k}` is a number 0..1 `{k}` is een getal 0..1 +@msg `{k}` is true or false `{k}` is true of false +@msg `{k}` isn't an export setting: {_1} `{k}` is geen exportinstelling: {_1} +@msg `{k}` must be a byte offset `{k}` moet een bytepositie zijn +@msg `{k}` must be a number `{k}` moet een getal zijn +@msg `{k}` must be a number of points `{k}` moet een aantal punten zijn +@msg `{k}` must be a string `{k}` moet een string zijn +@msg `{k}` must be a whole number `{k}` moet een geheel getal zijn +@msg `{k}` must be from {lo} to {hi} for {key} `{k}` moet van {lo} tot {hi} zijn voor {key} +@msg `{k}` must be {what}, not {v} `{k}` moet {what} zijn, niet {v} +@msg `{l}` isn't a global or spot colour `{l}` is geen globale kleur of steunkleur +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` is geen bitmap: gebruik clipboard.importSvg, importPdf of importText +@msg `{name}` can't be deleted `{name}` kan niet worden verwijderd +@msg `{name}` can't be duplicated `{name}` kan niet worden gedupliceerd +@msg `{name}` can't be edited `{name}` kan niet worden bewerkt +@msg `{name}` can't be edited this way `{name}` kan niet op deze manier worden bewerkt +@msg `{name}` can't be renamed `{name}` kan geen andere naam krijgen +@msg `{name}` can't name a folder `{name}` kan geen naam van een map zijn +@msg `{name}` has no art to place `{name}` heeft geen illustratie om te plaatsen +@msg `{name}` has no text to place `{name}` heeft geen tekst om te plaatsen +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text `{name}` in het Word-document is geen UTF-8- of UTF-16-tekst +@msg `{name}` in the Word document is not well-formed XML: {e} `{name}` in het Word-document is geen welgevormde XML: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep `{name}` in het Word-document nest elementen meer dan {MAX_NESTING} niveaus diep +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` is een ingebouwde voorinstelling en is alleen-lezen: sla deze onder een andere naam op +@msg `{name}` is a built-in preset: save under another name `{name}` is een ingebouwde voorinstelling: sla op onder een andere naam +@msg `{name}` is a built-in profile and stays `{name}` is een ingebouwd profiel en blijft bestaan +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` is een afzonderlijk bestand, dat alleen naast het pad van het DCS-bestand kan worden gevonden +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` is een tint van `{base}`: bewerk `{base}` (een tintstaal accepteert alleen een nieuwe naam) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` is een werkruimte: kies eerst een andere met edit.colorSettings +@msg `{name}` is built in and stays `{name}` is ingebouwd en blijft bestaan +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` accepteert een verf van zijn eigen soort (kleur, verloop of patroon) +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted `{n}` kan niet worden verwijderd +@msg `{n}` can't be merged `{n}` kan niet worden samengevoegd +@msg `{n}` can't be moved `{n}` kan niet worden verplaatst +@msg `{n}` is not a colour swatch `{n}` is geen kleurstaal +@msg `{n}` isn't a solid colour `{n}` is geen effen kleur +@msg `{n}`: colour groups hold solid colours only `{n}`: kleurgroepen bevatten alleen effen kleuren +@msg `{other}` doesn't trace an image `{other}` traceert geen afbeelding +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: de verwijzingen van de stap kopiëren meer dan {_1} MB aan waarden +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` is geen astag: een tag bestaat uit vier tekens, zoals "wght" +@msg `{to}` is empty or already used `{to}` is leeg of al in gebruik +@msg `{u}` is no Unicode character `{u}` is geen Unicode-teken +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` is langer dan {MAX_TEXT} bytes +@msg `{what}` must be text `{what}` moet tekst zijn +@msg `{word}` isn't in the custom dictionary `{word}` staat niet in het aangepaste woordenboek +@msg a CMap has too many mappings een CMap heeft te veel toewijzingen +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard een DXF-tekening heeft één oorsprong: geef useArtboards: true op voor één tekening per tekengebied +@msg a PDF presets file with over {_1} presets een bestand met PDF-voorinstellingen met meer dan {_1} voorinstellingen +@msg a \\u escape needs four hexadecimal digits een \\u-escape heeft vier hexadecimale cijfers nodig +@msg a batch preview cannot switch documents een batchvoorvertoning kan niet van document wisselen +@msg a brush named `{nn}` exists er bestaat al een penseel met de naam `{nn}` +@msg a comma in an XPath expression must separate function arguments een komma in een XPath-expressie moet functieargumenten scheiden +@msg a container of the listed objects is gone een container van de vermelde objecten is verdwenen +@msg a content stream can't be read een inhoudsstroom kan niet worden gelezen +@msg a content stream isn't a stream een inhoudsstroom is geen stroom +@msg a design holds at most {_1} objects een ontwerp bevat maximaal {_1} objecten +@msg a design needs a name een ontwerp heeft een naam nodig +@msg a dictionary grew too large een woordenboek is te groot geworden +@msg a document holds at most {_1} SVG filters een document bevat maximaal {_1} SVG-filters +@msg a document keeps at most {_1} graph designs een document bewaart maximaal {_1} grafiekontwerpen +@msg a document keeps at most {_1} saved selections een document bewaart maximaal {_1} opgeslagen selecties +@msg a document keeps at most {_1} views een document bewaart maximaal {_1} weergaven +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files een document koppelt maximaal {MAX_SVG_SCRIPTS} JavaScript-bestanden +@msg a document with unsaved changes has no recovery copy een document met niet-opgeslagen wijzigingen heeft geen herstelkopie +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes de orde van een feConvolveMatrix-filter is {x}×{y}, waardoor de vermenigvuldiging van de kernelgrootte in usvg overloopt +@msg a font's data is too large de gegevens van een lettertype zijn te groot +@msg a font's subroutines are too large de subroutines van een lettertype zijn te groot +@msg a function declaration without a name een functiedeclaratie zonder naam +@msg a gradient preset is already called {n} er heet al een verloopvoorinstelling {n} +@msg a graph has at most {_1} series, so it transposes with at most that many categories een grafiek heeft maximaal {_1} reeksen, dus kan hij met hoogstens zoveel categorieën worden getransponeerd +@msg a host value has too many entries een hostwaarde heeft te veel items +@msg a host value is nested too deep een hostwaarde is te diep genest +@msg a layer can't be merged into its own sublayer een laag kan niet in zijn eigen sublaag worden samengevoegd +@msg a layer's form box isn't an array het kader van het formulier van een laag is geen array +@msg a layer's form can't be read het formulier van een laag kan niet worden gelezen +@msg a layer's form has no box het formulier van een laag heeft geen kader +@msg a layer's form has no dictionary het formulier van een laag heeft geen woordenboek +@msg a legend draws at most {MAX_CHIPS} swatches een legenda tekent maximaal {MAX_CHIPS} stalen +@msg a linked JavaScript file is outside the read roots een gekoppeld JavaScript-bestand staat buiten de leesroots +@msg a name names one asset: give multiple: false, or one object een naam noemt één asset: geef multiple: false, of één object +@msg a namespace declaration uses a reserved prefix or URI een naamruimtedeclaratie gebruikt een gereserveerd voorvoegsel of een gereserveerde URI +@msg a namespace qualifier is undefined een naamruimtekwalificatie is niet gedefinieerd +@msg a namespace with an empty URI cannot have a prefix een naamruimte met een lege URI kan geen voorvoegsel hebben +@msg a number has no digits een getal heeft geen cijfers +@msg a number's exponent has no digits de exponent van een getal heeft geen cijfers +@msg a number's fraction has no digits het breukdeel van een getal heeft geen cijfers +@msg a page can't be read een pagina kan niet worden gelezen +@msg a page object is used by something else een paginaobject wordt door iets anders gebruikt +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) een paginaminiatuur moet uit RGB-pixels bestaan, 1 tot {THUMBNAIL_SIZE} per zijde (kreeg {w} × {h}) +@msg a pattern layer doesn't end what it starts een patroonlaag sluit niet af wat hij begint +@msg a pattern layer's colors hold something else de kleuren van een patroonlaag bevatten iets anders +@msg a pattern named `{n}` exists er bestaat al een patroon met de naam `{n}` +@msg a plug-in with id {_1} is already installed er is al een plug-in met id {_1} geïnstalleerd +@msg a preset named `{n}` exists er bestaat al een voorinstelling met de naam `{n}` +@msg a print presets file with over {_1} presets een bestand met afdrukvoorinstellingen met meer dan {_1} voorinstellingen +@msg a profile is already called {n} er heet al een profiel {n} +@msg a property descriptor cannot have both a value or writable and a get or set een eigenschapsdescriptor kan niet zowel value of writable als get of set hebben +@msg a property descriptor must be an object een eigenschapsdescriptor moet een object zijn +@msg a property's {name} must be a function or undefined de {name} van een eigenschap moet een functie of undefined zijn +@msg a row can't go into itself een rij kan niet in zichzelf worden geplaatst +@msg a series goes on the left or the right value axis een reeks hoort op de linker- of de rechterwaardeas +@msg a start must be an anchor index ≥ 0 or null een start moet een ankerindex ≥ 0 of null zijn +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied een stroom erin zou tot meer dan 256 MB worden gedecodeerd, of de decodeerparameters ervan kunnen niet worden toegepast +@msg a string holds a control character een string bevat een besturingsteken +@msg a string holds an escape sequence JSON does not have een string bevat een escapereeks die JSON niet kent +@msg a string is not closed een string is niet afgesloten +@msg a style named `{name}` already exists er bestaat al een stijl met de naam `{name}` +@msg a style named `{to}` already exists er bestaat al een stijl met de naam `{to}` +@msg a swatch needs a colour, gradient or pattern een staal heeft een kleur, verloop of patroon nodig +@msg a symbol named `{n}` already exists er bestaat al een symbool met de naam `{n}` +@msg a theme has 1 to {THEME_SIZE} colours een thema heeft 1 tot {THEME_SIZE} kleuren +@msg a theme is already called {name} (replace: "{name}" overwrites it) er heet al een thema {name} (replace: "{name}" overschrijft het) +@msg a toolbar name has at most {MAX_NAME} characters de naam van een werkbalk heeft maximaal {MAX_NAME} tekens +@msg a view named `{_1}` exists er bestaat al een weergave met de naam `{_1}` +@msg a view named `{name}` exists er bestaat al een weergave met de naam `{name}` +@msg a workspace keeps at most {MAX} toolbars een werkruimte bewaart maximaal {MAX} werkbalken +@msg a {_1} series can't go in a {_2} graph een reeks van het type {_1} kan niet in een grafiek van het type {_2} staan +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-compatibel, de standaard) of "7" (oude .ai, versie 7) +@msg already editing an opacity mask er wordt al een dekkingmasker bewerkt +@msg already editing another object's opacity mask het dekkingmasker van een ander object wordt al bewerkt +@msg already in pattern editing mode de patroonbewerkingsmodus is al actief +@msg an SVG filter is named by its id: give `xml` with `name` een SVG-filter wordt bij zijn id genoemd: geef `xml` met `name` +@msg an XML element has two attributes with the same name een XML-element heeft twee attributen met dezelfde naam +@msg an XML value cannot be inserted into itself or its descendants een XML-waarde kan niet in zichzelf of in zijn afstammelingen worden ingevoegd +@msg an XML value's children cannot be assigned by position de onderliggende elementen van een XML-waarde kunnen niet op positie worden toegewezen +@msg an XML value's children cannot be deleted by position de onderliggende elementen van een XML-waarde kunnen niet op positie worden verwijderd +@msg an XMLList converts to XML only when it has one item een XMLList wordt alleen naar XML geconverteerd wanneer deze één item heeft +@msg an XPath node test is missing er ontbreekt een XPath-knooppunttest +@msg an XPath operand is missing er ontbreekt een XPath-operand +@msg an XPath operator is missing er ontbreekt een XPath-operator +@msg an XPath predicate or step cannot follow here hier kan geen XPath-predicaat of -stap volgen +@msg an XPath predicate or step follows a value that is not a node-set een XPath-predicaat of -stap volgt op een waarde die geen knooppuntenset is +@msg an XPath step is missing er ontbreekt een XPath-stap +@msg an artboard is listed twice een tekengebied staat twee keer in de lijst +@msg an empty artboard has no width or height to scale to een leeg tekengebied heeft geen breedte of hoogte om naar te schalen +@msg an image couldn't be decoded and is missing from the picture een afbeelding kon niet worden gedecodeerd en ontbreekt in het beeld +@msg an image is too large for DXF and was left out een afbeelding is te groot voor DXF en is weggelaten +@msg an image of {_1} MB, at most {_2} MB een afbeelding van {_1} MB, maximaal {_2} MB +@msg an image of {declared_w} × {declared_h} pixels is too large to draw een afbeelding van {declared_w} × {declared_h} pixels is te groot om te tekenen +@msg an image of {width} × {height} pixels is too large to draw een afbeelding van {width} × {height} pixels is te groot om te tekenen +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) een afbeelding van {w} × {h} pixels is te groot om te converteren (maximaal {_1} pixels) +@msg an index must be a whole number een index moet een geheel getal zijn +@msg an inline graphic has no glyphs een inline afbeelding heeft geen glyphs +@msg an invisible text form can't be read een formulier met onzichtbare tekst kan niet worden gelezen +@msg an object is missing er ontbreekt een object +@msg an object's header is missing de kop van een object ontbreekt +@msg an overprinting form can't be read een overdrukkend formulier kan niet worden gelezen +@msg an unread object has no readable bounds een ongelezen object heeft geen leesbare grenzen +@msg an unread object holds a frame of threaded type een ongelezen object bevat een kader van gekoppelde tekst +@msg an unread object is missing from its layer een ongelezen object ontbreekt in zijn laag +@msg anchor `smooth` must be true or false `smooth` van het anker moet true of false zijn +@msg anchor `{key}` must be [x, y] `{key}` van het anker moet [x, y] zijn +@msg anchor `{key}` must be a number `{key}` van het anker moet een getal zijn +@msg angle and roundness must be finite hoek en ronding moeten eindig zijn +@msg angles must be a list as long as pins (degrees or null) angles moet een lijst zijn die even lang is als pins (graden of null) +@msg appearance must be flat|center|edge appearance moet flat|center|edge zijn +@msg apply is "fill" or "stroke", not `{other}` apply is "fill" of "stroke", niet `{other}` +@msg apply was called on a value that is not a function apply is aangeroepen op een waarde die geen functie is +@msg area must be a closed polygon of 3 to 100000 [x, y] points area moet een gesloten polygoon van 3 tot 100000 punten [x, y] zijn +@msg area type in a thread cannot use Dynamic Text: {frames} vlaktekst in gekoppelde tekst kan geen dynamische tekst gebruiken: {frames} +@msg area type needs a path that encloses an area vlaktekst heeft een pad nodig dat een oppervlak omsluit +@msg area type {_1} is named twice vlaktekst {_1} wordt twee keer genoemd +@msg array literal too long arrayliteral te lang +@msg arrowAlign must be extend|tip, got {o} arrowAlign moet extend|tip zijn, kreeg {o} +@msg arrowScale must be [start, end] arrowScale moet [start, end] zijn +@msg artboard is a 0-based index artboard is een index die bij 0 begint +@msg artboard must be a 0-based index artboard moet een index zijn die bij 0 begint +@msg artboard {i} doesn't print tekengebied {i} wordt niet afgedrukt +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout moet {layout?, columns?, spacing?, rightToLeft?} zijn +@msg artboardLayout.columns must be 1 or more artboardLayout.columns moet 1 of meer zijn +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout moet gridByRow, gridByColumn, row of column zijn +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft moet true of false zijn +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing moet 0–{MAX_SPACING} pt zijn +@msg artboards must be a non-empty list artboards moet een niet-lege lijst zijn +@msg artboards must be a number artboards moet een getal zijn +@msg asset `{_1}` has no art to export asset `{_1}` heeft geen illustratie om te exporteren +@msg assignment to a constant binding toewijzing aan een constante binding +@msg at least one row that isn't excluded needs a weight above 0 minstens één rij die niet is uitgesloten heeft een gewicht boven 0 nodig +@msg at most {MAX_KEYWORDS} keywords maximaal {MAX_KEYWORDS} trefwoorden +@msg at most {MAX_MATCHES} matches are kept er worden maximaal {MAX_MATCHES} overeenkomsten bewaard +@msg at most {MAX_POINTS} points maximaal {MAX_POINTS} punten +@msg at most {MAX_QUEUE} files maximaal {MAX_QUEUE} bestanden +@msg at must be [x, y] at moet [x, y] zijn +@msg at/rect lie outside the canvas at/rect liggen buiten het canvas +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute moet size|leading|tracking|kerning|baselineShift zijn +@msg attrs must be an object attrs moet een object zijn +@msg automation path rejected: the folder's name isn't valid text automatiseringspad geweigerd: de naam van de map is geen geldige tekst +@msg automation path rejected: {_1} isn't valid text automatiseringspad geweigerd: {_1} is geen geldige tekst +@msg automation path rejected: {why}: {path} automatiseringspad geweigerd: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis moet "vertical", "horizontal" of een hoek in graden zijn, niet `{given}` +@msg backgroundContents must be transparent or white backgroundContents moet transparent of white zijn +@msg bad AES key ongeldige AES-sleutel +@msg bad `runs`: {e} ongeldige `runs`: {e} +@msg bad arrowScale ongeldige arrowScale +@msg bad base64 ongeldige base64 +@msg bad color {c} ongeldige kleur {c} +@msg bad colour ongeldige kleur +@msg bad colour in `colors` ongeldige kleur in `colors` +@msg bad colour {v} ongeldige kleur {v} +@msg bad colour {v} in groupColors ongeldige kleur {v} in groupColors +@msg bad cross-reference entry ongeldig item in de kruisverwijzingstabel +@msg bad cross-reference table ongeldige kruisverwijzingstabel +@msg bad current colour {v} ongeldige huidige kleur {v} +@msg bad dataBase64 ongeldige dataBase64 +@msg bad edit ongeldige bewerking +@msg bad fill {v} ongeldige vulling {v} +@msg bad fit: {e} ongeldige passing: {e} +@msg bad model {other} (keep, new or document) ongeldig model {other} (keep, new of document) +@msg bad new colour {v} ongeldige nieuwe kleur {v} +@msg bad object ongeldig object +@msg bad object number ongeldig objectnummer +@msg bad offset ongeldige positie +@msg bad path data: {e} ongeldige padgegevens: {e} +@msg bad point {v}: expected [x, y] ongeldig punt {v}: verwacht [x, y] +@msg bad point {v}: expected [x, y] or [x, y, pressure] ongeldig punt {v}: verwacht [x, y] of [x, y, pressure] +@msg bad reference ongeldige verwijzing +@msg bad trailer size ongeldige grootte in de trailer +@msg bad {k} colour ongeldige kleur voor {k} +@msg batches cannot nest batches kunnen niet worden genest +@msg bind something to a variable first koppel eerst iets aan een variabele +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed moet 0–{MAX_BLEED} pt zijn: een getal, [top, bottom, left, right] of {{top, bottom, left, right}} +@msg blend must be a mode name blend moet de naam van een modus zijn +@msg both sides of an XPath | must select nodes beide zijden van een XPath-| moeten knooppunten selecteren +@msg brush must be a name or null brush moet een naam of null zijn +@msg by must be a number by moet een getal zijn +@msg can't add page thumbnails: {why} kan geen paginaminiaturen toevoegen: {why} +@msg can't add the editing data for other apps: {why} kan de bewerkingsgegevens voor andere apps niet toevoegen: {why} +@msg can't edit the written PDF: {why} kan de geschreven PDF niet bewerken: {why} +@msg can't encrypt the PDF: {why} kan de PDF niet versleutelen: {why} +@msg can't linearise the PDF: {why} kan de PDF niet lineariseren: {why} +@msg can't open `{_1}`: {_2} kan `{_1}` niet openen: {_2} +@msg can't open `{_1}`: {e} kan `{_1}` niet openen: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) kan `{name}` niet openen: geen indeling die VectorCraft leest (zie document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) kan `{name}` niet plaatsen: geen indeling die VectorCraft leest (zie document.formats) +@msg can't read back the written PDF: {e} kan de geschreven PDF niet teruglezen: {e} +@msg can't read this Word document: {why} kan dit Word-document niet lezen: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp kan geen .{e}-afbeeldingen schrijven: gebruik .png, .jpg, .gif, .webp, .tif of .bmp +@msg can't write PDF layers, overprint or invisible text: {why} kan geen PDF-lagen, overdruk of onzichtbare tekst schrijven: {why} +@msg can't write the file's copyright: {why} kan het copyright van het bestand niet schrijven: {why} +@msg can't write the file's modification date: {why} kan de wijzigingsdatum van het bestand niet schrijven: {why} +@msg can't write the pages' user units: {why} kan de gebruikerseenheden van de pagina’s niet schrijven: {why} +@msg canceled geannuleerd +@msg cannot convert the object to a primitive value kan het object niet naar een primitieve waarde converteren +@msg cannot convert undefined or null to an object kan undefined of null niet naar een object converteren +@msg cannot distort a zero-size or non-finite bounding box kan een selectiekader zonder grootte of met niet-eindige afmetingen niet vervormen +@msg cannot move an object into itself kan een object niet in zichzelf verplaatsen +@msg case must be upper|lower|title|sentence case moet upper|lower|title|sentence zijn +@msg category `{c}`: one of {_1} categorie `{c}`: een van {_1} +@msg center must be [x, y] center moet [x, y] zijn +@msg character dimensions cannot be represented with the source text transform tekenafmetingen kunnen niet worden weergegeven met de teksttransformatie van de bron +@msg character dimensions cannot be represented with the target text transform tekenafmetingen kunnen niet worden weergegeven met de teksttransformatie van het doel +@msg choose at least one artboard kies minstens één tekengebied +@msg choose at least one status kies minstens één status +@msg clip must be true or false clip moet true of false zijn +@msg clippingMask must be true or false clippingMask moet true of false zijn +@msg closed must be a boolean closed moet een boolean zijn +@msg collapsed must be true or false collapsed moet true of false zijn +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: er is geen RGB- of CMYK-profiel met de naam „{profile}” +@msg colorMode must be rgb or cmyk, not `{m}` colorMode moet rgb of cmyk zijn, niet `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel moet {own} (de modus van het document), grayscale of bitmap zijn +@msg colour groups can't go into colour groups kleurgroepen kunnen niet in kleurgroepen worden geplaatst +@msg colour, mode and spot apply to solid-colour swatches only colour, mode en spot gelden alleen voor stalen met een effen kleur +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read kolom {column}: `{cell}` is een `%`-kolom van grafiekgegevensbestanden, die niet worden gelezen +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable kolom {column}: `{cell}` noemt `{name}`, en dat is een variabele van het type {_1} +@msg column {column}: `{cell}` names no variable kolom {column}: `{cell}` noemt geen variabele +@msg column {column}: the variable name is longer than {MAX_NAME} characters kolom {column}: de naam van de variabele is langer dan {MAX_NAME} tekens +@msg column: true floats the whole column and takes no onto, above or below column: true laat de hele kolom zweven en accepteert geen onto, above of below +@msg command must be a shape.* command command moet een shape.*-opdracht zijn +@msg commands[{i}]: missing `command` commands[{i}]: `command` ontbreekt +@msg composer must be "singleLine" or "everyLine" composer moet "singleLine" of "everyLine" zijn +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes samengestelde vormen accepteren paden, samengestelde paden, groepen, samengestelde vormen, tekst, mengsels en omvormingen +@msg compound {_1} must contain exactly one open path to join samengesteld object {_1} moet precies één open pad bevatten om te verbinden +@msg connect needs a path other than the one extended connect heeft een ander pad nodig dan het pad dat wordt verlengd +@msg connect needs id connect heeft id nodig +@msg connect path has no open end het pad van connect heeft geen open uiteinde +@msg continuation must be an array of path chunks continuation moet een array van padstukken zijn +@msg continuation requires points continuation vereist points +@msg copy needs path: the file to copy into the document's folder copy heeft path nodig: het bestand dat naar de map van het document wordt gekopieerd +@msg copy takes images only: relink placed documents where their files are copy accepteert alleen afbeeldingen: koppel geplaatste documenten opnieuw op de plek waar hun bestanden staan +@msg copying a file isn't available on the web een bestand kopiëren is niet beschikbaar op het web +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus moet unknown, copyrighted of publicDomain zijn +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl moet een URL zijn (geen spaties, maximaal 2048 tekens) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners moet uit 4 punten [x, y] bestaan (LB, RB, RO, LO) +@msg counterPer `{c}`: item or file counterPer `{c}`: item of file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: alleen PDF-pagina’s hebben art-, trim-, bleed- en mediakaders; gebruik "crop" of "bounding" +@msg crop rectangle doesn't overlap the image de bijsnijdrechthoek overlapt de afbeelding niet +@msg cross-reference and object streams aren't supported kruisverwijzings- en objectstromen worden niet ondersteund +@msg damaged compressed data: {e} beschadigde gecomprimeerde gegevens: {e} +@msg dash lengths must be numbers, got {v} streeplengten moeten getallen zijn, kreeg {v} +@msg dash must be a list of lengths or null, got {v} dash moet een lijst met lengten of null zijn, kreeg {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) gegevenssleutel `{k}`: letters, cijfers, -, _ en . (niet name of vc-…) +@msg data must be an object of key: value, not {v} data moet een object van sleutel: waarde zijn, niet {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare gegevensset `{name}` bevat een waarde voor `{var}`, die de bibliotheek niet declareert +@msg data set `{name}` holds two values for `{var}` gegevensset `{name}` bevat twee waarden voor `{var}` +@msg data set `{name}` is declared twice gegevensset `{name}` is twee keer gedeclareerd +@msg data takes at most {MAX_DATA} keys at once data accepteert maximaal {MAX_DATA} sleutels tegelijk +@msg dataset `{name}` already exists gegevensset `{name}` bestaat al +@msg dataset `{to}` already exists gegevensset `{to}` bestaat al +@msg date `{d}`: YYYY-MM-DD datum `{d}`: YYYY-MM-DD +@msg decoded data is too large de gedecodeerde gegevens zijn te groot +@msg define a variable first definieer eerst een variabele +@msg detect must be true or false detect moet true of false zijn +@msg direct-select anchor points of a path selecteer met directe selectie ankerpunten van een pad +@msg distance must be > 0 distance moet > 0 zijn +@msg dx and dy give the arrow's direction dx en dy geven de richting van de pijl +@msg dx and dy must be numbers dx en dy moeten getallen zijn +@msg dxf fitTo must be [width, height], positive dxf fitTo moet [breedte, hoogte] zijn, positief +@msg dxf must be an object of DXF import options dxf moet een object met DXF-importopties zijn +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale moet een positief getal tot en met {_1} zijn, niet {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: een eenheid zoals mm, cm, in of pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} elke indeling is een object {format, scale?, suffix?} +@msg each item needs an id elk item heeft een id nodig +@msg each match needs `kind` elke overeenkomst heeft `kind` nodig +@msg each match needs a `name` elke overeenkomst heeft een `name` nodig +@msg each of `fonts` is {family, style?}; 1 to 1000 of them elk item van `fonts` is {family, style?}; 1 tot 1000 stuks +@msg each of `items` is {kind, item} elk item van `items` is {kind, item} +@msg each of `items` is {{id, corners?}} with an object id, not {it} elk item van `items` is {{id, corners?}} met een object-id, niet {it} +@msg each path chunk must contain 1 to 4096 points elk padstuk moet 1 tot 4096 punten bevatten +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) elke keuze is {{at: [x, y]}} of {{rect: [x0, y0, x1, y1]}} (kreeg {v}) +@msg each row needs `from` elke rij heeft `from` nodig +@msg each stop needs a position elke stop heeft een positie nodig +@msg each subpath needs an anchors array elk subpad heeft een array anchors nodig +@msg each weight must be a number of 0 or more (got {v}) elk gewicht moet een getal van 0 of meer zijn (kreeg {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType moet easeIn, easeOut, easeInOut of easeOutIn zijn +@msg editingData must be true or false editingData moet true of false zijn +@msg editingData must be true, false or "always" editingData moet true, false of "always" zijn +@msg edits overlap bewerkingen overlappen elkaar +@msg effects of objects whose appearances differ can't be moved together effecten van objecten met een verschillende vormgeving kunnen niet samen worden verplaatst +@msg empty header in column {_1} lege koptekst in kolom {_1} +@msg encoding `{_1}`: utf8 or utf16 codering `{_1}`: utf8 of utf16 +@msg ends must be two of "first" and "last" ends moet uit twee waarden "first" en "last" bestaan +@msg every numbered name for the result is taken elke genummerde naam voor het resultaat is al in gebruik +@msg expanded SVG styles exceed the import size limit uitgevouwen SVG-stijlen overschrijden de groottelimiet voor importeren +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes het bestand met exportvoorinstellingen is groter dan {MAX_PRESET_FILE_BYTES} bytes +@msg exportText must be editable or appearance exportText moet editable of appearance zijn +@msg exportType must be movieClip or graphic, not `{s}` exportType moet movieClip of graphic zijn, niet `{s}` +@msg extend needs id extend heeft id nodig +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges bevat maximaal {MAX_FEATURE_CHANGES} tags +@msg featureChanges must be a list of tags featureChanges moet een lijst met tags zijn +@msg features must be a list of tags features moet een lijst met tags zijn +@msg fidelity must be 0..100 fidelity moet 0..100 zijn +@msg files must be {name, dataBase64} files moet {name, dataBase64} zijn +@msg finally table {t} out of range finally-tabel {t} buiten het bereik +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit moet none|autoHeight|shrinkText|block zijn, kreeg {id} +@msg flags cannot be given with a RegExp object flags kunnen niet samen met een RegExp-object worden opgegeven +@msg flattener: {e} afvlakker: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low of een opgeslagen voorinstelling (zie flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile moet along|across zijn, kreeg {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item of itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels mappen dieper dan {MAX_BATCH_DEPTH} niveaus +@msg folders deeper than {MAX_DEPTH} levels mappen dieper dan {MAX_DEPTH} niveaus +@msg font family `{family}` is not available lettertypefamilie `{family}` is niet beschikbaar +@msg font family `{tf}` is not available lettertypefamilie `{tf}` is niet beschikbaar +@msg format `{f}`: pdf or postscript format `{f}`: pdf of postscript +@msg format `{f}`: text or json format `{f}`: text of json +@msg format is `{format}` de indeling is `{format}` +@msg formats must be an array of rows, not {v} formats moet een array van rijen zijn, niet {v} +@msg frequency must be between 1 and 300 lines per inch frequency moet tussen 1 en 300 lijnen per inch liggen +@msg from and to must be finite from en to moeten eindig zijn +@msg from must be [x0, y0, x1, y1] from moet [x0, y0, x1, y1] zijn +@msg function declaration index out of range index van functiedeclaratie buiten het bereik +@msg give `before` or `after`, not both geef `before` of `after`, niet beide +@msg give `char`, `text`, `code` or `unicode` geef `char`, `text`, `code` of `unicode` +@msg give `colors`, or `color` and `rule` geef `colors`, of `color` en `rule` +@msg give `count` or `spacing`, not both geef `count` of `spacing`, niet beide +@msg give `event`, such as onclick geef `event`, zoals onclick +@msg give `file`, the JavaScript file's path or URL geef `file`, het pad of de URL van het JavaScript-bestand +@msg give `fill` and/or `stroke` geef `fill` en/of `stroke` +@msg give `fill`, `stroke` or both geef `fill`, `stroke` of beide +@msg give `folder` geef `folder` +@msg give `fonts`, or open a document geef `fonts`, of open een document +@msg give `from` or `indices`, not both geef `from` of `indices`, niet beide +@msg give `id` (no text is being edited) geef `id` (er wordt geen tekst bewerkt) +@msg give `id` and `anchors`, or `items`, not both geef `id` en `anchors`, of `items`, niet beide +@msg give `id` or `guide`, not both geef `id` of `guide`, niet beide +@msg give `length` or `to` geef `length` of `to` +@msg give `matches`: [{kind, name, target}] geef `matches`: [{kind, name, target}] +@msg give `name` (or edit a pattern) geef `name` (of bewerk een patroon) +@msg give `name` or `names` geef `name` of `names` +@msg give `name`, an SVG filter's name geef `name`, de naam van een SVG-filter +@msg give `path` (a .wasm file) or `dataBase64` (the module) geef `path` (een .wasm-bestand) of `dataBase64` (de module) +@msg give `path`, `data` or `dataBase64` geef `path`, `data` of `dataBase64` +@msg give `path`, `data`, `dataBase64` or `csv` geef `path`, `data`, `dataBase64` of `csv` +@msg give `path`, `xml` or `dataBase64` geef `path`, `xml` of `dataBase64` +@msg give `path`, an SVG file, or `svg`, SVG text geef `path`, een SVG-bestand, of `svg`, SVG-tekst +@msg give `script`, the JavaScript the event runs ("" removes the event) geef `script`, de JavaScript die de gebeurtenis uitvoert ("" verwijdert de gebeurtenis) +@msg give `text` (the reading) or `remove` geef `text` (de lezing) of `remove` +@msg give `x` and `y` (and a `tolerance` of 0 or more) geef `x` en `y` (en een `tolerance` van 0 of meer) +@msg give `xml`, one SVG element geef `xml`, één SVG-element +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments geef een modus of aanpassingen voor r/g/b, c/m/y/k, gray of tint +@msg give a name, or paint the active proxy with a saved preset geef een naam, of verf de actieve proxy met een opgeslagen voorinstelling +@msg give a name, or select a stroke with a saved profile geef een naam, of selecteer een contour met een opgeslagen profiel +@msg give a pattern name or select an object painted with a pattern geef de naam van een patroon of selecteer een object dat met een patroon is geverfd +@msg give at least one of clip, invert, disabled, linked geef minstens een van clip, invert, disabled, linked +@msg give color, none, swatch or gradient geef color, none, swatch of gradient +@msg give dataBase64 geef dataBase64 +@msg give either at (a click) or area (a path drawn around the overlaps) geef at (een klik) of area (een pad dat rond de overlappingen is getekend) +@msg give exactly one of `at`, `rect` and `points` geef precies een van `at`, `rect` en `points` +@msg give exactly one of `path`, `data` and `dataBase64` geef precies een van `path`, `data` en `dataBase64` +@msg give formats or a preset, not both geef formats of een voorinstelling, niet beide +@msg give index or indexes, not both geef index of indexes, niet beide +@msg give newName or delete geef newName of delete +@msg give offset or by geef offset of by +@msg give one element geef één element +@msg give one of `to`, `rect` or `at` geef een van `to`, `rect` of `at` +@msg give one of assets, layers, selection or fullDocument geef een van assets, layers, selection of fullDocument +@msg give only one of onto, above, below, icon and a column place geef slechts een van onto, above, below, icon en een kolomplaats +@msg give opacity or blend geef opacity of blend +@msg give origin: [x, y], or reset: true geef origin: [x, y], of reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note geef overprintFill, overprintStroke, showCenter, imageMap, url of note +@msg give path (a file) or folder geef path (een bestand) of folder +@msg give path, or name and dataBase64 geef path, of name en dataBase64 +@msg give paths or files geef paths of files +@msg give rows, rowHeight, columns or columnWidth geef rows, rowHeight, columns of columnWidth +@msg give svg, or dataBase64 of an SVG or SVGZ file geef svg, of dataBase64 van een SVG- of SVGZ-bestand +@msg give text geef text +@msg give the asset a name geef de asset een naam +@msg give the preset a `name` geef de voorinstelling een `name` +@msg give the preset a name geef de voorinstelling een naam +@msg give the preset a name (1–64 characters) geef de voorinstelling een naam (1–64 tekens) +@msg give the settings to change, e.g. {"formats"}]} geef de instellingen die moeten worden gewijzigd, bijv. {"formats"}]} +@msg give the style a name geef de stijl een naam +@msg give two or more style `names` geef twee of meer `names` van stijlen +@msg give two or more swatches geef twee of meer stalen +@msg give x, y, or a handle's length or angle geef x, y, of de lengte of hoek van een handgreep +@msg give {key, value} or {values} geef {key, value} of {values} +@msg global must be true or false global moet true of false zijn +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) glyph {_1} wordt niet getekend voor {ch} met de stijl van het teken (zoals hoofdletters, kleinkapitalen of een cijfersysteem) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} glyph {g} is geen alternatief voor {ch} in {_1} {_2} +@msg gradient must be "objects" or "mesh" gradient moet "objects" of "mesh" zijn +@msg grays must be "profile" or "black", not `{g}` grays moet "profile" of "black" zijn, niet `{g}` +@msg gridColors must be a preset name or two colours gridColors moet de naam van een voorinstelling of twee kleuren zijn +@msg gridSize must be small, medium or large gridSize moet small, medium of large zijn +@msg guide {g} is not selected hulplijn {g} is niet geselecteerd +@msg handle must be 0..3 handle moet 0..3 zijn +@msg handler at {_1} expects {_2} values but its range starts with {d} de handler op {_1} verwacht {_2} waarden, maar het bereik ervan begint met {d} +@msg handler depth too large handlerdiepte te groot +@msg handler range {_1}..{_2} out of range handlerbereik {_1}..{_2} buiten het bereik +@msg hiddenLayers must be true or false hiddenLayers moet true of false zijn +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (de standaard), "omit" of "visible" +@msg highlight a layer to merge into markeer een laag om in samen te voegen +@msg highlight two or more rows in one layer or group markeer twee of meer rijen in één laag of groep +@msg highlight two or more rows to merge markeer twee of meer rijen om samen te voegen +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon dockt een paneel in de pictogramkolom: gebruik window.panel.dock +@msg id must be a dimension id id moet de id van een maatlijn zijn +@msg image has no bounds de afbeelding heeft geen grenzen +@msg image {_1} is linked already afbeelding {_1} is al gekoppeld +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client of server +@msg images in a spot ink whose samples couldn't be read were imported in RGB afbeeldingen in een steunkleurinkt waarvan de samples niet konden worden gelezen, zijn in RGB geïmporteerd +@msg images past the most pixels one file may hold were left out afbeeldingen boven het maximale aantal pixels dat één bestand mag bevatten, zijn weggelaten +@msg images too large to read were left out afbeeldingen die te groot zijn om te lezen, zijn weggelaten +@msg index out of range index buiten het bereik +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes mag maximaal {MAX_GUIDE_INDEXES} items bevatten +@msg indexes must be an array of guide indexes indexes moet een array van hulplijnindexen zijn +@msg instances must be 1..1000 instances moet 1..1000 zijn +@msg instruction out of range instructie buiten het bereik +@msg instruction {pc} ({op}) pops below the stack instructie {pc} ({op}) haalt meer van de stack dan erop staat +@msg instruction {pc} reached with stack depths {seen} and {d} instructie {pc} bereikt met stackdiepten {seen} en {d} +@msg invalid `color` ongeldige `color` +@msg invalid anchor index {v}: expected a non-negative integer ongeldige ankerindex {v}: verwacht een niet-negatief geheel getal +@msg invalid anchor reference {v}: expected [subpath, anchor] ongeldige ankerverwijzing {v}: verwacht [subpad, anker] +@msg invalid angle ongeldige hoek +@msg invalid array length ongeldige arraylengte +@msg invalid brush: {e} ongeldig penseel: {e} +@msg invalid color ongeldige kleur +@msg invalid colour {c} ongeldige kleur {c} +@msg invalid delta ongeldige delta +@msg invalid group ongeldige groep +@msg invalid object id {v}: expected a non-negative integer ongeldige object-id {v}: verwacht een niet-negatief geheel getal +@msg invalid point ongeldig punt +@msg invalid regular expression flag '{_1}' ongeldige vlag '{_1}' voor reguliere expressie +@msg invalid regular expression: {m} ongeldige reguliere expressie: {m} +@msg invalid size ongeldige grootte +@msg invalid t ongeldige t +@msg invalid time value ongeldige tijdwaarde +@msg invalid tolerance ongeldige tolerantie +@msg is a confirm's answer, whose `value` must be true or false is het antwoord op een confirm, waarvan `value` true of false moet zijn +@msg is a prompt's answer, whose `value` must be a string or null is het antwoord op een prompt, waarvan `value` een string of null moet zijn +@msg is an alert's answer, whose `value` must be "ok" is het antwoord op een alert, waarvan `value` "ok" moet zijn +@msg is longer than 16 KiB is langer dan 16 KiB +@msg it draws its image through a clipping path het tekent zijn afbeelding door een knippad +@msg it draws no image het tekent geen afbeelding +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes het is groter dan {MAX_DICTIONARY_FILE_BYTES} bytes +@msg it isn't a file het is geen bestand +@msg it names {_1} plates, and at most {MAX_PLATES} are read het noemt {_1} platen, en er worden er maximaal {MAX_PLATES} gelezen +@msg its art is too long or too detailed for a head de illustratie ervan is te lang of te gedetailleerd voor een kop +@msg its art paints nothing de illustratie ervan verft niets +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) alle tekengebieden ervan liggen buiten het canvas (meer dan {_1} pt van de oorsprong) +@msg its clipping masks are nested too deeply de knipmaskers ervan zijn te diep genest +@msg its image is too large to read de afbeelding ervan is te groot om te lezen +@msg its metadata has no description de metagegevens ervan hebben geen beschrijving +@msg its metadata has no direct length de metagegevens ervan hebben geen directe lengte +@msg its plates are too large: more than {MAX_SAMPLES} pixels together de platen ervan zijn te groot: samen meer dan {MAX_SAMPLES} pixels +@msg its plates are too large: {_1} plates of {w} × {h} pixels de platen ervan zijn te groot: {_1} platen van {w} × {h} pixels +@msg its plates can't be kept together de platen ervan kunnen niet bij elkaar worden gehouden +@msg its shapes could not be combined into one de vormen ervan konden niet tot één vorm worden gecombineerd +@msg join two different paths verbind twee verschillende paden +@msg jump to {pc}, past the end sprong naar {pc}, voorbij het einde +@msg justify must be auto|left|center|right|justifyAll justify moet auto|left|center|right|justifyAll zijn +@msg kbps must be a positive number, not {kbps} kbps moet een positief getal zijn, niet {kbps} +@msg keywords must be a list of words or a comma-separated string keywords moet een lijst met woorden of een door komma’s gescheiden string zijn +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords mogen maximaal {MAX_KEYWORD} tekens lang zijn, zonder besturingstekens +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind moet 1, 2 of 3 zijn, of geef de `name` van een voorinstelling (zie perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind moet graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle of paraStyle zijn +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout moet "on", "off", "neutral" of een boolean zijn, niet {v} +@msg layer `{name}` has no art to export laag `{name}` heeft geen illustratie om te exporteren +@msg layer must name a layer or sublayer layer moet een laag of sublaag noemen +@msg layers can't be intertwined: select the objects in them lagen kunnen niet worden verstrengeld: selecteer de objecten erin +@msg layers go only at the top level or in layers, objects in layers and groups lagen staan alleen op het hoogste niveau of in lagen, objecten in lagen en groepen +@msg layers must be true or false layers moet true of false zijn +@msg layout must be gridByRow, gridByColumn, row or column layout moet gridByRow, gridByColumn, row of column zijn +@msg leading must be a number or "auto" leading moet een getal of "auto" zijn +@msg left/right must be non-negative widths left/right moeten niet-negatieve breedten zijn +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding moet een van {_1} zijn +@msg level `{t}`: 2 or 3 niveau `{t}`: 2 of 3 +@msg line {line}: more than {MAX_CSV_COLS} columns regel {line}: meer dan {MAX_CSV_COLS} kolommen +@msg line {line}: unclosed quote regel {line}: niet-afgesloten aanhalingsteken +@msg line {line}: value for `{var}` must match its variable kind regel {line}: de waarde voor `{var}` moet bij het soort variabele passen +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf of crlf +@msg loading profiles from disk isn't available in the browser profielen van schijf laden is niet beschikbaar in de browser +@msg localize: the first argument must be a localization object or a string localize: het eerste argument moet een lokalisatieobject of een string zijn +@msg margin: a {m} pt margin leaves no imageable area on the paper marge: een marge van {m} pt laat geen afdrukbaar gebied op het papier over +@msg matrix must be finite and invertible, depth finite matrix moet eindig en inverteerbaar zijn, depth eindig +@msg matte: {e} mat: {e} +@msg member is for order front, forward, backward or back member is bedoeld voor order front, forward, backward of back +@msg missing `artboards` `artboards` ontbreekt +@msg missing `at` [x, y] `at` [x, y] ontbreekt +@msg missing `attrs` `attrs` ontbreekt +@msg missing `color` or `opacity` `color` of `opacity` ontbreekt +@msg missing `colors` `colors` ontbreekt +@msg missing `commands` `commands` ontbreekt +@msg missing `destinationFolder` `destinationFolder` ontbreekt +@msg missing `effect` id de id `effect` ontbreekt +@msg missing `family` `family` ontbreekt +@msg missing `find` `find` ontbreekt +@msg missing `gid` (a glyph id, or null for the default glyph) `gid` ontbreekt (een glyph-id, of null voor de standaardglyph) +@msg missing `group` `group` ontbreekt +@msg missing `id` `id` ontbreekt +@msg missing `id`: give one, or select one type object `id` ontbreekt: geef er een, of selecteer één tekstobject +@msg missing `index` (a point index, or null to clear) `index` ontbreekt (een puntindex, of null om te wissen) +@msg missing `index` (a stop index, or null to clear) `index` ontbreekt (een stopindex, of null om te wissen) +@msg missing `index` (an item index or null) `index` ontbreekt (een itemindex of null) +@msg missing `item` `item` ontbreekt +@msg missing `library` `library` ontbreekt +@msg missing `library` (see graphicStyle.libraries) `library` ontbreekt (zie graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) `library` ontbreekt (zie swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) `map` ontbreekt (rijen [{from, to}] of {current}) +@msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] ontbreekt +@msg missing `name` (see gradient.presets.list) `name` ontbreekt (zie gradient.presets.list) +@msg missing `offset` (a distance in points) `offset` ontbreekt (een afstand in punten) +@msg missing `path` `path` ontbreekt +@msg missing `path` (a path object id) `path` ontbreekt (de id van een padobject) +@msg missing `picks` `picks` ontbreekt +@msg missing `rule` `rule` ontbreekt +@msg missing `source` id de id `source` ontbreekt +@msg missing `start` (the byte offset of a character) `start` ontbreekt (de bytepositie van een teken) +@msg missing `status`: linked, embedded, missing or modified `status` ontbreekt: linked, embedded, missing of modified +@msg missing `target` id de id `target` ontbreekt +@msg missing `text` `text` ontbreekt +@msg missing `to` `to` ontbreekt +@msg missing `to` (an index) `to` ontbreekt (een index) +@msg missing `to`: a non-negative integer `to` ontbreekt: een niet-negatief geheel getal +@msg missing `type` `type` ontbreekt +@msg missing `value` `value` ontbreekt +@msg missing `word` `word` ontbreekt +@msg missing `{key}` `{key}` ontbreekt +@msg missing `{k}` `{k}` ontbreekt +@msg missing anchor anker ontbreekt +@msg missing anchors ankers ontbreken +@msg missing anchors [[subpath, anchor]…] anchors [[subpad, anker]…] ontbreekt +@msg missing array `{key}` array `{key}` ontbreekt +@msg missing case case ontbreekt +@msg missing color color ontbreekt +@msg missing commands commands ontbreekt +@msg missing destination folder doelmap ontbreekt +@msg missing from from ontbreekt +@msg missing group group ontbreekt +@msg missing id or ids id of ids ontbreekt +@msg missing ids ids ontbreekt +@msg missing indexes indexes ontbreekt +@msg missing matrix [a,b,c,d,e,f] matrix [a,b,c,d,e,f] ontbreekt +@msg missing name name ontbreekt +@msg missing op op ontbreekt +@msg missing or invalid `color` `color` ontbreekt of is ongeldig +@msg missing or unknown `tool` `tool` ontbreekt of is onbekend +@msg missing point punt ontbreekt +@msg missing point [x,y] punt [x,y] ontbreekt +@msg missing point `{key}` ([x, y]) punt `{key}` ([x, y]) ontbreekt +@msg missing points points ontbreekt +@msg missing points [[x,y]…] points [[x,y]…] ontbreekt +@msg missing segment segment ontbreekt +@msg missing stops stops ontbreekt +@msg missing subpaths subpaths ontbreekt +@msg missing target target ontbreekt +@msg missing to to ontbreekt +@msg missing x, y x, y ontbreekt +@msg mode is set, add or toggle, not `{other}` mode is set, add of toggle, niet `{other}` +@msg mode must be "cmyk" or "rgb" mode moet "cmyk" of "rgb" zijn +@msg mode must be area|onPath, got {o} mode moet area|onPath zijn, kreeg {o} +@msg more than 10000 linked files: package dependencies in smaller parts meer dan 10000 gekoppelde bestanden: verpak de afhankelijkheden in kleinere delen +@msg more than 256 commands meer dan 256 opdrachten +@msg more than {MAX_BATCH_FILES} data sets meer dan {MAX_BATCH_FILES} gegevenssets +@msg more than {MAX_BATCH_FILES} files meer dan {MAX_BATCH_FILES} bestanden +@msg more than {MAX_CSV_ROWS} data rows meer dan {MAX_CSV_ROWS} gegevensrijen +@msg more than {MAX_FILES} images meer dan {MAX_FILES} afbeeldingen +@msg more than {MAX_IMPORT} presets meer dan {MAX_IMPORT} voorinstellingen +@msg moved must be [[x,y]…] moved moet [[x,y]…] zijn +@msg must be an object with `kind` and `value` moet een object met `kind` en `value` zijn +@msg must have `kind` alert, confirm or prompt moet `kind` alert, confirm of prompt hebben +@msg must have exactly `kind` and `value` moet precies `kind` en `value` hebben +@msg name (a toolbar's name) is required name (de naam van een werkbalk) is vereist +@msg name (a toolbar's name) must be a string name (de naam van een werkbalk) moet een string zijn +@msg name must be a toolbar's name name moet de naam van een werkbalk zijn +@msg names is empty names is leeg +@msg names must be an array of symbol names names moet een array van symboolnamen zijn +@msg need `anchor` or `segment` + `t` `anchor` of `segment` + `t` is nodig +@msg need at least one anchor er is minstens één anker nodig +@msg need at least one point er is minstens één punt nodig +@msg need at least two points er zijn minstens twee punten nodig +@msg needs a closing quote after the name heeft een afsluitend aanhalingsteken na de naam nodig +@msg needs a name heeft een naam nodig +@msg needs a name of at most 64 bytes heeft een naam van maximaal 64 bytes nodig +@msg needs a name of letters, digits and _, or a quoted name heeft een naam van letters, cijfers en _ nodig, of een naam tussen aanhalingstekens +@msg needs a name without control characters heeft een naam zonder besturingstekens nodig +@msg needs points heeft punten nodig +@msg nesting too deep te diep genest +@msg nineSlice must be true or false nineSlice moet true of false zijn +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides moet [left, top, right, bottom] zijn, elk -16..16, met left + right < 1 en top + bottom < 1 +@msg no HP-GL format geen HP-GL-indeling +@msg no New Document profile called `{shown}` ({_1}) geen profiel voor nieuwe documenten met de naam `{shown}` ({_1}) +@msg no PDF preset named `{n}` geen PDF-voorinstelling met de naam `{n}` +@msg no SVG filter at index {k} geen SVG-filter op index {k} +@msg no action set is current er is geen actieset actief +@msg no active data set geen actieve gegevensset +@msg no appearance item {index} geen vormgevingsitem {index} +@msg no arrowheads file here hier is geen bestand met pijlpunten +@msg no artboard {_1} geen tekengebied {_1} +@msg no artboard {artboard} (0-based; the document has {_1}) geen tekengebied {artboard} (begint bij 0; het document heeft er {_1}) +@msg no asset {id} geen asset {id} +@msg no asset {id} (see assets.list) geen asset {id} (zie assets.list) +@msg no assets to export geen assets om te exporteren +@msg no assets yet: collect art for export first (assets.add) nog geen assets: verzamel eerst illustraties voor export (assets.add) +@msg no brush named `{n}` geen penseel met de naam `{n}` +@msg no character at byte {start} geen teken op byte {start} +@msg no character has code {code} geen teken heeft code {code} +@msg no colour geen kleur +@msg no colour group `{group}` geen kleurgroep `{group}` +@msg no colour group `{name}` geen kleurgroep `{name}` +@msg no custom arrowhead {name} (see stroke.arrowheads.list) geen aangepaste pijlpunt {name} (zie stroke.arrowheads.list) +@msg no data sets geen gegevenssets +@msg no dataset named `{from}` geen gegevensset met de naam `{from}` +@msg no dataset named `{name}` geen gegevensset met de naam `{name}` +@msg no datasets geen gegevenssets +@msg no design named `{name}` geen ontwerp met de naam `{name}` +@msg no design named `{n}` geen ontwerp met de naam `{n}` +@msg no design named `{old}` geen ontwerp met de naam `{old}` +@msg no encoder for {_1} yet nog geen encoder voor {_1} +@msg no export preset `{name}` (see document.exportPresets.list) geen exportvoorinstelling `{name}` (zie document.exportPresets.list) +@msg no file to open geen bestand om te openen +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder geen map: geef `path` op of stel Voorkeuren › Plug-ins & Scratchschijven › Extra map met plug-ins in +@msg no fonts to look for: they are all available geen lettertypen om te zoeken: ze zijn allemaal beschikbaar +@msg no formats to export geen indelingen om te exporteren +@msg no gradient preset `{name}` (see gradient.presets.list) geen verloopvoorinstelling `{name}` (zie gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` geen grafische stijl `{n}` in `{_1}` +@msg no graphic style library `{key}` (see graphicStyle.libraries) geen bibliotheek met grafische stijlen `{key}` (zie graphicStyle.libraries) +@msg no guide selected geen hulplijn geselecteerd +@msg no guide selected (or give an index) geen hulplijn geselecteerd (of geef een index) +@msg no guide {i} geen hulplijn {i} +@msg no images in the folder geen afbeeldingen in de map +@msg no instructions geen instructies +@msg no item at index {_1} geen item op index {_1} +@msg no item at index {from} geen item op index {from} +@msg no item at one of the indices {idx} geen item op een van de indexen {idx} +@msg no layer geen laag +@msg no layer to rename geen laag om te hernoemen +@msg no layer {id} (see layer.list) geen laag {id} (zie layer.list) +@msg no layers to export geen lagen om te exporteren +@msg no library `{key}` geen bibliotheek `{key}` +@msg no metafile format geen metafile-indeling +@msg no object {_1} geen object {_1} +@msg no objects geen objecten +@msg no parent layer geen bovenliggende laag +@msg no perspective grid preset named `{b}` (see perspective.presets.list) geen voorinstelling voor het perspectiefraster met de naam `{b}` (zie perspective.presets.list) +@msg no perspective grid preset named `{n}` geen voorinstelling voor het perspectiefraster met de naam `{n}` +@msg no point selected (give `index`) geen punt geselecteerd (geef `index`) +@msg no preset called `{from}` (see file.newPresets) geen voorinstelling met de naam `{from}` (zie file.newPresets) +@msg no preset called `{name}` (see file.newPresets) geen voorinstelling met de naam `{name}` (zie file.newPresets) +@msg no preset named `{n}` geen voorinstelling met de naam `{n}` +@msg no print preset named `{b}` (see print.presets.list) geen afdrukvoorinstelling met de naam `{b}` (zie print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) geen afdrukvoorinstelling met de naam `{name}` (voorinstellingen: {_1}) +@msg no print preset named `{n}` geen afdrukvoorinstelling met de naam `{n}` +@msg no profile named `{name}` geen profiel met de naam `{name}` +@msg no random numbers ({e}) geen willekeurige getallen ({e}) +@msg no readable files in the folder geen leesbare bestanden in de map +@msg no recovery copy `{f}` (see file.recovery.list) geen herstelkopie `{f}` (zie file.recovery.list) +@msg no rows geen rijen +@msg no saved PDF preset named `{name}` geen opgeslagen PDF-voorinstelling met de naam `{name}` +@msg no saved export preset `{name}` geen opgeslagen exportvoorinstelling `{name}` +@msg no saved gradient preset called {name} geen opgeslagen verloopvoorinstelling met de naam {name} +@msg no saved gradient presets geen opgeslagen verloopvoorinstellingen +@msg no saved preset called `{name}` geen opgeslagen voorinstelling met de naam `{name}` +@msg no saved presets to export (name [Default] in `names` to export it) geen opgeslagen voorinstellingen om te exporteren (noem [Default] in `names` om die te exporteren) +@msg no saved presets to export (name built-in ones in `names`) geen opgeslagen voorinstellingen om te exporteren (noem ingebouwde in `names`) +@msg no saved print preset named `{name}` geen opgeslagen afdrukvoorinstelling met de naam `{name}` +@msg no saved profile called {name} geen opgeslagen profiel met de naam {name} +@msg no saved selection `{name}` geen opgeslagen selectie `{name}` +@msg no saved theme called {name} (see colorTheme.list) geen opgeslagen thema met de naam {name} (zie colorTheme.list) +@msg no saved views geen opgeslagen weergaven +@msg no selected or given object has a visible 3D effect geen enkel geselecteerd of opgegeven object heeft een zichtbaar 3D-effect +@msg no series {i} geen reeks {i} +@msg no slice {n} in the image (see slice.list) geen slice {n} in de afbeelding (zie slice.list) +@msg no source art geen bronillustratie +@msg no stop {i} (the gradient has {n}) geen stop {i} (het verloop heeft er {n}) +@msg no style `{name}` geen stijl `{name}` +@msg no such artboard {v} tekengebied {v} bestaat niet +@msg no such assets (see assets.list) deze assets bestaan niet (zie assets.list) +@msg no such frame anchor, or the frame would reach past the canvas dit kaderanker bestaat niet, of het kader zou voorbij het canvas reiken +@msg no such objects deze objecten bestaan niet +@msg no such path anchor, or the path would reach past the canvas dit padanker bestaat niet, of het pad zou voorbij het canvas reiken +@msg no such rows deze rijen bestaan niet +@msg no swatch `{keep}` geen staal `{keep}` +@msg no swatch `{n}` geen staal `{n}` +@msg no swatch `{n}` (swatches and colour groups move separately) geen staal `{n}` (stalen en kleurgroepen worden apart verplaatst) +@msg no swatch library `{key}` (see swatch.library.list) geen stalenbibliotheek `{key}` (zie swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) geen stalenbibliotheek `{key}` (zie swatch.library.list; "document": de stalen van het document) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) geen stalenbibliotheek `{key}` (zie swatch.library.list; "{DOCUMENT_SWATCHES}": de stalen van het document) +@msg no swatch named `{n}` geen staal met de naam `{n}` +@msg no swatch or colour group `{n}` geen staal of kleurgroep `{n}` +@msg no swatch or colour group `{n}` in `{_1}` geen staal of kleurgroep `{n}` in `{_1}` +@msg no symbol named `{_1}` geen symbool met de naam `{_1}` +@msg no symbol named `{name}` geen symbool met de naam `{name}` +@msg no text objects selected geen tekstobjecten geselecteerd +@msg no toolbar named `{_1}` geen werkbalk met de naam `{_1}` +@msg no toolbar named `{name}` geen werkbalk met de naam `{name}` +@msg no tracing preset `{preset}` (see imageTrace.presets) geen traceervoorinstelling `{preset}` (zie imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) hier is geen map voor gebruikersbibliotheken (sla op met een pad, of zonder pad om de gegevens te krijgen) +@msg no variable named `{from}` geen variabele met de naam `{from}` +@msg no variable named `{name}` geen variabele met de naam `{name}` +@msg no variable named `{var}` geen variabele met de naam `{var}` +@msg no variable named `{v}` geen variabele met de naam `{v}` +@msg no view named `{name}` geen weergave met de naam `{name}` +@msg node {_1} already has the text variable `{_2}` knooppunt {_1} heeft de tekstvariabele `{_2}` al +@msg node {_1} is not a graph knooppunt {_1} is geen grafiek +@msg node {_1} is not an image knooppunt {_1} is geen afbeelding +@msg node {_1} is not type knooppunt {_1} is geen tekst +@msg not a DCS file geen DCS-bestand +@msg not a PDF presets file (format `{_1}`) geen bestand met PDF-voorinstellingen (indeling `{_1}`) +@msg not a PDF presets file: {e} geen bestand met PDF-voorinstellingen: {e} +@msg not a RegExp object geen RegExp-object +@msg not a Word document: its WordDocument stream doesn't start with a file information block geen Word-document: de WordDocument-stroom ervan begint niet met een blok met bestandsinformatie +@msg not a Word document: its compound file has no WordDocument stream geen Word-document: het samengestelde bestand ervan heeft geen WordDocument-stroom +@msg not a Word document: its main part is not a WordprocessingML document geen Word-document: het hoofdonderdeel ervan is geen WordprocessingML-document +@msg not a Word document: its package has no main document part geen Word-document: het pakket ervan heeft geen hoofddocumentonderdeel +@msg not a closed path geen gesloten pad +@msg not a flattener presets file (format `{_1}`) geen bestand met afvlakkervoorinstellingen (indeling `{_1}`) +@msg not a flattener presets file: {e} geen bestand met afvlakkervoorinstellingen: {e} +@msg not a layer or group geen laag of groep +@msg not a library file: {e} geen bibliotheekbestand: {e} +@msg not a mesh envelope point geen punt van een netomvorming +@msg not a perspective grid presets file (format `{_1}`) geen bestand met voorinstellingen voor het perspectiefraster (indeling `{_1}`) +@msg not a perspective grid presets file: {e} geen bestand met voorinstellingen voor het perspectiefraster: {e} +@msg not a print presets file (format `{_1}`) geen bestand met afdrukvoorinstellingen (indeling `{_1}`) +@msg not a print presets file: {e} geen bestand met afdrukvoorinstellingen: {e} +@msg not a regular expression geen reguliere expressie +@msg not a stored or deflated central record geen opgeslagen of met deflate gecomprimeerd centraal record +@msg not a valid SVGZ file: {e} geen geldig SVGZ-bestand: {e} +@msg not an RTF file: it doesn't start with {\\rtf geen RTF-bestand: het begint niet met {\\rtf +@msg not an action set: the text doesn't start with /version and a number geen actieset: de tekst begint niet met /version en een getal +@msg not an image VectorCraft reads (see document.formats) geen afbeelding die VectorCraft leest (zie document.formats) +@msg not area type (text in a frame) outside perspective geen vlaktekst (tekst in een kader) buiten perspectief +@msg not editing an opacity mask er wordt geen dekkingmasker bewerkt +@msg not type on a path outside perspective geen tekst op een pad buiten perspectief +@msg nothing prints er wordt niets afgedrukt +@msg nothing to bind niets om te koppelen +@msg nothing to change niets om te wijzigen +@msg nothing to change: give dx, dy, hScale, vScale or rotation niets om te wijzigen: geef dx, dy, hScale, vScale of rotation +@msg nothing to crop niets om bij te snijden +@msg nothing to export niets om te exporteren +@msg nothing to export: the document has no visible art niets om te exporteren: het document heeft geen zichtbare illustraties +@msg nothing to mark niets om te markeren +@msg nothing to print: every artboard picked is blank niets om af te drukken: elk gekozen tekengebied is leeg +@msg nothing to print: the document has no art that prints niets om af te drukken: het document heeft geen illustraties die worden afgedrukt +@msg nothing to repeat niets om te herhalen +@msg numbers out of order in {_1} quantifier getallen in de verkeerde volgorde in kwantor {_1} +@msg object is not a path het object is geen pad +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object objecttransparantie kan niet op gemarkeerde tekens worden overgenomen; schakel Transparantie uit in Opties voor pipet of selecteer het hele object +@msg object {_1} can't be set as point type object {_1} kan niet als punttekst worden gezet +@msg object {_1} has lost its file's contents: relink it object {_1} is de inhoud van zijn bestand kwijt: koppel het opnieuw +@msg object {_1} has no path object {_1} heeft geen pad +@msg object {_1} has no size object {_1} heeft geen grootte +@msg object {_1} is hidden or locked, or on a locked artboard object {_1} is verborgen of vergrendeld, of staat op een vergrendeld tekengebied +@msg object {_1} is not a placed document object {_1} is geen geplaatst document +@msg object {_1} is not an Intertwine group object {_1} is geen groep Verstrengelen +@msg object {_1} is not an image object {_1} is geen afbeelding +@msg object {_1} is not an image or a linked file object {_1} is geen afbeelding of gekoppeld bestand +@msg object {_1} is not an image or a placed document object {_1} is geen afbeelding of geplaatst document +@msg object {_1} is not in this Intertwine group object {_1} zit niet in deze groep Verstrengelen +@msg object {_1} needs a 3D effect object {_1} heeft een 3D-effect nodig +@msg object {_1} needs a 3D effect at index {index} object {_1} heeft een 3D-effect op index {index} nodig +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic object {_1}: een holle Extruderen en afschuinen heeft geen kappen: schakel Kap in om een afbeelding toe te wijzen +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front object {_1}: met een diepte van 0 heeft het object één vlak: wijs de afbeeldingen ervan toe aan de voorkant +@msg object {_1}: {_2} object {_1}: {_2} +@msg object {_1}: {e} object {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams object {_1}: {name} op index {index} heeft geen belichtingsvoorinstellingen; stel de lichten ervan in met effect.setParams +@msg object {id} has anchors 0–{_1} for `corners`, not {past} object {id} heeft de ankers 0–{_1} voor `corners`, niet {past} +@msg object {id} has {n} anchors, so no anchor {past} object {id} heeft {n} ankers, dus geen anker {past} +@msg objects must be none, move or copy objects moet none, move of copy zijn +@msg objects were left out er zijn objecten weggelaten +@msg offset out of range positie buiten het bereik +@msg on must be true or false on moet true of false zijn +@msg only groups, layers, type and images have a Contents (Characters) row alleen groepen, lagen, tekst en afbeeldingen hebben een rij Inhoud (Tekens) +@msg only interior mesh lines can be deleted alleen binnenste netlijnen kunnen worden verwijderd +@msg only solid colours can be added (not gradients or patterns) alleen effen kleuren kunnen worden toegevoegd (geen verlopen of patronen) +@msg only solid-colour swatches can be spot colours alleen stalen met een effen kleur kunnen steunkleuren zijn +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first alleen de ingesloten voorvertoning van dit Affinity-bestand kon worden gelezen: gebruik Bestand › Openen om die met de bijbehorende waarschuwing te bekijken, of exporteer eerst SVG of PDF vanuit Affinity +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning alleen de ingesloten voorvertoning van dit Affinity-bestand kon worden gelezen; gebruik Bestand › Openen om die met de bijbehorende waarschuwing te bekijken +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon en een kolomplaats horen bij window.panel.float en window.panel.dock +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon en een kolomplaats accepteren geen anchor, side, zone of before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon en een kolomplaats accepteren geen x, y, width of height +@msg opacity must be a number opacity moet een getal zijn +@msg open a document to look for its linked files open een document om de gekoppelde bestanden ervan te zoeken +@msg operand too large operand te groot +@msg options must be an object (see file.formatOptions) options moet een object zijn (zie file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order is een geheel getal van 0 tot {others}, niet `{v}` +@msg order must be leftToRight or rightToLeft order moet leftToRight of rightToLeft zijn +@msg orientation must be portrait or landscape, not `{o}` orientation moet portrait of landscape zijn, niet `{o}` +@msg outlineText {v}: true or false outlineText {v}: true of false +@msg output intent profile: {e} profiel van de uitvoerintentie: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: er is geen RGB- of CMYK-profiel met de naam „{name}” +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1}-bestanden zijn CMYK, en „{name}” is een RGB-profiel +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: er is geen inkt ingesteld om af te drukken +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: er is geen profiel met de naam „{name}”, en een uitvoerintentie voor {std} sluit haar profiel in +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: er is geen profiel met de naam „{name}”: een uitvoerintentie voor {std} sluit een profiel in, of noemt een geregistreerde drukconditie (outputConditionId en registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std}-bestanden worden in CMYK afgedrukt, en „{name}” is een RGB-profiel +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: „{name}” is een grijsprofiel, dat geen uitvoerintentie voor {std} kan zijn +@msg overlap: {o} pt is half a tile or more overlap: {o} pt is een halve tegel of meer +@msg overlapping Shaper paths must be in the same layer or group overlappende Shaper-paden moeten in dezelfde laag of groep staan +@msg overlapping edits overlappende bewerkingen +@msg page art conflicts with image data for {key} de pagina-illustratie is in strijd met de afbeeldingsgegevens voor {key} +@msg page art conflicts with pattern {name} de pagina-illustratie is in strijd met patroon {name} +@msg page art conflicts with swatch {name} de pagina-illustratie is in strijd met staal {name} +@msg page art is missing image data for {key} in de pagina-illustratie ontbreken afbeeldingsgegevens voor {key} +@msg page art is missing pattern {name} in de pagina-illustratie ontbreekt patroon {name} +@msg page art is missing swatch {name} in de pagina-illustratie ontbreekt staal {name} +@msg page must be a whole number from 1 page moet een geheel getal vanaf 1 zijn +@msg page thumbnail: {e} paginaminiatuur: {e} +@msg page {page} of the PDF has no objects pagina {page} van de PDF heeft geen objecten +@msg page {page}: `{file}` has {n} {artboards} pagina {page}: `{file}` heeft {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} pagina {page}: `{name}` heeft {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages moet een bereik zoals "2-3" of een paginanummer zijn, niet {v} +@msg pages: {e} pagina’s: {e} +@msg panel position must be finite de paneelpositie moet eindig zijn +@msg parameters must be an object de parameters moeten een object zijn +@msg params must be an object params moet een object zijn +@msg passwords can have up to {MAX_PASSWORD} bytes wachtwoorden mogen maximaal {MAX_PASSWORD} bytes hebben +@msg path chunks must share their boundary point padstukken moeten hun grenspunt delen +@msg path has no open end het pad heeft geen open uiteinde +@msg paths must be strings paths moet uit strings bestaan +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false vereist dat elk tekengebied de bewerkbare illustratie behoudt +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false vereist dat de tekengebieden de bewerkbare illustratie behouden wanneer useArtboards false is +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single of multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom is "artwork" of "swatches", niet {v} +@msg pins and moved must be non-empty lists of the same length pins en moved moeten niet-lege lijsten van dezelfde lengte zijn +@msg pins must be [[x,y]…] pins moet [[x,y]…] zijn +@msg pins must be finite pins moet eindig zijn +@msg place is above, below or inside, not `{other}` place is above, below of inside, niet `{other}` +@msg plane must be left|right|ground plane moet left|right|ground zijn +@msg plane must be left|right|ground|none plane moet left|right|ground|none zijn +@msg point is outside the mesh het punt ligt buiten het net +@msg point x must be a number x van het punt moet een getal zijn +@msg point y must be a number y van het punt moet een getal zijn +@msg points must be [t, left, right] with non-negative widths points moet uit [t, left, right] met niet-negatieve breedten bestaan +@msg points must be [x,y] pairs points moet uit paren [x,y] bestaan +@msg points must be a non-empty [[x,y,pressure?]…] list points moet een niet-lege lijst [[x,y,pressure?]…] zijn +@msg points must be a non-empty list of finite [x,y] points moet een niet-lege lijst van eindige [x,y] zijn +@msg points must be an array or null points moet een array of null zijn +@msg points must be finite points moet eindig zijn +@msg points must be finite [x,y] pairs points moet uit eindige paren [x,y] bestaan +@msg points must be finite canvas points points moet uit eindige canvaspunten bestaan +@msg points must contain 1–4096 coordinates points moet 1–4096 coördinaten bevatten +@msg position must be normal, superscript or subscript position moet normal, superscript of subscript zijn +@msg position must be topLeft, topRight, bottomLeft or bottomRight position moet topLeft, topRight, bottomLeft of bottomRight zijn +@msg positions must be between 0 and 100000 pt positions moeten tussen 0 en 100000 pt liggen +@msg ppi must be a positive number, not {ppi} ppi moet een positief getal zijn, niet {ppi} +@msg ppi must be between 1 and 2400 ppi moet tussen 1 en 2400 liggen +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` is niet transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape moet clippingMask|transparency zijn +@msg preset `{id}`: mobile or density preset `{id}`: mobile of density +@msg preset must be standard, diffuse, topLeft or right preset moet standard, diffuse, topLeft of right zijn +@msg preset must be the name of a print preset preset moet de naam van een afdrukvoorinstelling zijn +@msg previewMode must be default, pixel or overprint previewMode moet default, pixel of overprint zijn +@msg print settings: {e} afdrukinstellingen: {e} +@msg procedures nest too deeply procedures zijn te diep genest +@msg procedures nested too deeply procedures te diep genest +@msg quotes must be {double?, single?} quotes moet {double?, single?} zijn +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} moet een openend en een sluitend aanhalingsteken zijn, bijv. "“”" +@msg range out of order in character class bereik in de verkeerde volgorde in tekenklasse +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi moet 1–2400 zijn (72 scherm, 150 gemiddeld, 300 hoog) +@msg rating must be a whole number from 0 to {MAX_RATING} rating moet een geheel getal van 0 tot {MAX_RATING} zijn +@msg rect and {k} contradict each other: give the area or the artboards rect en {k} spreken elkaar tegen: geef het gebied of de tekengebieden +@msg rect applies to raster formats, not {_1} rect geldt voor rasterindelingen, niet voor {_1} +@msg rect is [x, y, width, height] in points rect is [x, y, breedte, hoogte] in punten +@msg rect must be [x, y, width, height] rect moet [x, y, breedte, hoogte] zijn +@msg rect must be [x, y, width, height] with a positive width and height rect moet [x, y, breedte, hoogte] met een positieve breedte en hoogte zijn +@msg rect needs finite numbers and a positive width and height rect heeft eindige getallen en een positieve breedte en hoogte nodig +@msg redraw needs id redraw heeft id nodig +@msg redraw within must be a finite distance of 0 or more within van redraw moet een eindige afstand van 0 of meer zijn +@msg reduce of an empty array with no initial value reduce van een lege array zonder beginwaarde +@msg registration must be 0..8 registration moet 0..8 zijn +@msg regular expression flag '{_1}' given twice vlag '{_1}' voor reguliere expressie twee keer opgegeven +@msg release a layer or a group geef een laag of een groep vrij +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace behoudt de plaats en de transformatie van het vervangen object: laat template, at en rect weg +@msg replace: select the one object to replace replace: selecteer het ene object dat moet worden vervangen +@msg resolution must be a number or screen|medium|high resolution moet een getal of screen|medium|high zijn +@msg resolution must be between 1 and 2400 ppi resolution moet tussen 1 en 2400 ppi liggen +@msg rotation must be a number of degrees rotation moet een aantal graden zijn +@msg rotation must be finite rotation moet eindig zijn +@msg rows and cols must be 1..50 rows en cols moeten 1..50 zijn +@msg rows and columns must be 1..500 rows en columns moeten 1..500 zijn +@msg rows of objects whose appearances differ can't be reordered together rijen van objecten met een verschillende vormgeving kunnen niet samen opnieuw worden geordend +@msg sampled text would exceed the canvas (coordinates out of range) de overgenomen tekst zou buiten het canvas vallen (coördinaten buiten het bereik) +@msg save the document first: Package collects a saved document sla het document eerst op: Verpakken verzamelt een opgeslagen document +@msg save the document first: copy puts the file in its folder sla het document eerst op: copy plaatst het bestand in de map ervan +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: een factor zoals 2x, een breedte (100w), een hoogte (100h) of een resolutie (72ppi) +@msg scale must be a number above 0, at most 64 scale moet een getal boven 0 zijn, maximaal 64 +@msg scale must be non-zero scale mag niet nul zijn +@msg scope must be selection or all (got {other}) scope moet selection of all zijn (kreeg {other}) +@msg scribble is too long de krabbel is te lang +@msg segment does not exist het segment bestaat niet +@msg select a Shaper group selecteer een Shaper-groep +@msg select a blend to set easing or color shift selecteer een mengsel om de versnelling of de kleurverschuiving in te stellen +@msg select a compound shape selecteer een samengestelde vorm +@msg select a graph selecteer een grafiek +@msg select a group or layer with visible, unlocked members selecteer een groep of laag met zichtbare, ontgrendelde leden +@msg select a layer selecteer een laag +@msg select a text object selecteer een tekstobject +@msg select an SVG filter selecteer een SVG-filter +@msg select an image (or an Image Trace group) selecteer een afbeelding (of een groep Afbeelding traceren) +@msg select an image, or pass id selecteer een afbeelding, of geef id op +@msg select an object (or pass `id`) selecteer een object (of geef `id` op) +@msg select an object with a 3D effect selecteer een object met een 3D-effect +@msg select an object with a 3D effect, or give its id in ids selecteer een object met een 3D-effect, of geef de id ervan in ids +@msg select an object with a freeform gradient selecteer een object met een verloop in vrije vorm +@msg select an object with an opacity mask selecteer een object met een dekkingmasker +@msg select area type selecteer vlaktekst +@msg select area type (text in a frame) selecteer vlaktekst (tekst in een kader) +@msg select area type and closed paths (at least one area type object) selecteer vlaktekst en gesloten paden (minstens één vlaktekstobject) +@msg select area type whose first line can fit its frame selecteer vlaktekst waarvan de eerste regel in het kader past +@msg select artwork to add its colours selecteer illustraties om de kleuren ervan toe te voegen +@msg select artwork to make a group of its colours selecteer illustraties om een groep van de kleuren ervan te maken +@msg select artwork to recolour, or give a colour `group` selecteer illustraties om te herkleuren, of geef een `group` met kleuren +@msg select exactly two path ends to join selecteer precies twee paduiteinden om te verbinden +@msg select members of a compound shape selecteer leden van een samengestelde vorm +@msg select objects or give ids selecteer objecten of geef ids +@msg select objects to align to the guide selecteer objecten om op de hulplijn uit te lijnen +@msg select one Intertwine group selecteer één groep Verstrengelen +@msg select one image or pass id selecteer één afbeelding of geef id op +@msg select one layer, group or object to rename selecteer één laag, groep of object om te hernoemen +@msg select paths selecteer paden +@msg select paths or compound paths selecteer paden of samengestelde paden +@msg select rows or objects to collect selecteer rijen of objecten om te verzamelen +@msg select something to export selecteer iets om te exporteren +@msg select something to export (or turn off selectedOnly) selecteer iets om te exporteren (of schakel selectedOnly uit) +@msg select text to redefine the style from selecteer tekst om de stijl mee opnieuw te definiëren +@msg select the art and, on top of it, the mask object selecteer de illustratie en, daarbovenop, het maskerobject +@msg select the art for the design selecteer de illustratie voor het ontwerp +@msg select the art for the pattern tile selecteer de illustratie voor de patroontegel +@msg select the art to add first selecteer eerst de illustratie die moet worden toegevoegd +@msg select the art to collect (or give its ids) selecteer de illustratie die moet worden verzameld (of geef de id’s ervan) +@msg select the art to weed selecteer de illustratie die moet worden gewied +@msg select the banner, or mark every artboard selecteer de banner, of markeer elk tekengebied +@msg select the content and a path on top selecteer de inhoud en een pad erbovenop +@msg select the images to relink, or pass ids selecteer de afbeeldingen die opnieuw moeten worden gekoppeld, of geef ids op +@msg select the images, or pass ids selecteer de afbeeldingen, of geef ids op +@msg select the linked images or placed documents, or pass ids selecteer de gekoppelde afbeeldingen of geplaatste documenten, of geef ids op +@msg select the object to copy from selecteer het object waarvan moet worden gekopieerd +@msg select the objects (or give their ids) selecteer de objecten (of geef de id’s ervan) +@msg select the objects to attach to the path selecteer de objecten die aan het pad moeten worden gekoppeld +@msg select the objects to bind selecteer de objecten die moeten worden gekoppeld +@msg select two or more objects to intertwine selecteer twee of meer objecten om te verstrengelen +@msg select two or more slices to combine selecteer twee of meer slices om te combineren +@msg select type on a path selecteer tekst op een pad +@msg select type on a path or area type selecteer tekst op een pad of vlaktekst +@msg selection `{m}`: one or each selection `{m}`: one of each +@msg settings is an object of document.exportForScreens params settings is een object met parameters van document.exportForScreens +@msg settings must be an object settings moet een object zijn +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified of embedded +@msg show must be true or false show moet true of false zijn +@msg side is left or right, not `{v}` side is left of right, niet `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size moet "small", "medium", "large" of een aantal punten boven 0 tot en met {_1} zijn +@msg size must be positive size moet positief zijn +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode is "fixed" of "pressure", niet `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode is "fixed" of "pressure", niet {other} +@msg sizeVariation must be 0 or more sizeVariation moet 0 of meer zijn +@msg smallCaps must be true or false smallCaps moet true of false zijn +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind of status +@msg source paths are too large for Shaper de bronpaden zijn te groot voor Shaper +@msg source text transform is collapsed or unrepresentable de teksttransformatie van de bron is samengevallen of kan niet worden weergegeven +@msg spot, mode and group apply to solid colours only spot, mode en group gelden alleen voor effen kleuren +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing of modified +@msg steps must be 1..1000 steps moet 1..1000 zijn +@msg steps must be 1..{MAX_STEPS} steps moet 1..{MAX_STEPS} zijn +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) stop {i}: `plates` moet uit vier getallen van 0 tot 1 bestaan (C, M, Y, K) +@msg stopped before the archive was finished gestopt voordat het archief klaar was +@msg strokeMode must be within|along|across, got {m} strokeMode moet within|along|across zijn, kreeg {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions moet een object met opties van stroke.set zijn +@msg strokeWidth must be a positive number of pixels strokeWidth moet een positief aantal pixels zijn +@msg subpath {si} is not an open subpath subpad {si} is geen open subpad +@msg subpaths required when joining the same path subpaths is vereist bij het verbinden van hetzelfde pad +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict moet "merge" of "add" zijn, niet {v} +@msg table must be true or false table moet true of false zijn +@msg takes one name accepteert één naam +@msg target text transform is collapsed or unrepresentable de teksttransformatie van het doel is samengevallen of kan niet worden weergegeven +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text moet {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} zijn +@msg text transform is collapsed or unrepresentable de teksttransformatie is samengevallen of kan niet worden weergegeven +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces moet een geheel getal van 2 tot 100 zijn +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: gebruik "{_1}" of "{_2}" +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: geef {path} of {name, dataBase64} van een afbeeldingsbestand, of een textuur zoals effect.list die geeft +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices dat levert meer dan {MAX_PIECES} slices op +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer dat levert te veel stukken (maximaal 100000) of streepstappen op: vereenvoudig het streeppatroon of maak de strepen langer +@msg that object is not in the overlap dat object ligt niet in de overlapping +@msg the Affinity document has no page with a size het Affinity-document heeft geen pagina met een grootte +@msg the CMYK image couldn't be written de CMYK-afbeelding kon niet worden geschreven +@msg the Layers panel is not shown het paneel Lagen wordt niet getoond +@msg the Layers panel's search, filter or Show Layers Only hides its row de zoekopdracht, het filter of Alleen lagen weergeven van het paneel Lagen verbergt de rij ervan +@msg the Objects on Path group is hidden or locked de groep Objecten op pad is verborgen of vergrendeld +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected de PDF-beveiligingsinstellingen zijn zonder hun wachtwoorden opgeslagen: geef openPassword of permissionsPassword opnieuw, of security zonder passwordRequired om onbeveiligd te exporteren +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) het RTF-bestand heeft te veel alinea’s of opmaakwijzigingen om als tekst te plaatsen (maximaal {MAX_PARAGRAPHS} alinea’s en {MAX_RUNS} runs) +@msg the RTF file is too large to place as type (at most {_1} MB) het RTF-bestand is te groot om als tekst te plaatsen (maximaal {_1} MB) +@msg the SVG expands to more than {0} nested SVG images de SVG wordt uitgevouwen tot meer dan {0} geneste SVG-afbeeldingen +@msg the SVG expands to more than {0} objects de SVG wordt uitgevouwen tot meer dan {0} objecten +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes het SVG-bestand is groter dan {MAX_IMPORT_BYTES} bytes +@msg the SVG has no drawable objects de SVG heeft geen tekenbare objecten +@msg the SVGZ file unpacks to more than {_1} MB het SVGZ-bestand wordt uitgepakt tot meer dan {_1} MB +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place het Word-document heeft meer dan {MAX_PAGES} pagina’s met eigenschappen, te veel om te plaatsen +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place het Word-document heeft meer dan {MAX_RUNS} runs met teken- of alinea-eigenschappen, te veel om te plaatsen +@msg the Word document has more than {_1} MB of formatting properties, too many to place het Word-document heeft meer dan {_1} MB aan opmaakeigenschappen, te veel om te plaatsen +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) het Word-document heeft te veel tekst of opmaak om als tekst te plaatsen (maximaal {MAX_PARAGRAPHS} alinea’s, {MAX_RUNS} runs en {_1} MB tekst) +@msg the Word document has too much text to place as type (at most {_1} MB) het Word-document heeft te veel tekst om als tekst te plaatsen (maximaal {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it het Word-document is versleuteld of met een wachtwoord beveiligd; sla het zonder wachtwoord op om het te plaatsen +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) het Word-document is te groot om te plaatsen (de stroom {name} ervan bevat meer dan {_1} MB) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off het Word-document is het laatst opgeslagen met snel opslaan, wat VectorCraft niet leest; sla het opnieuw op met snel opslaan uitgeschakeld +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document het Word-document is opgeslagen door Word 6.0 of Word 95, wat VectorCraft niet leest; sla het op als een Word 97-2003-document +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place de tekst van het Word-document bestaat uit meer dan {MAX_PIECES} stukken, te veel om te plaatsen +@msg the XML operation needs an XML or XMLList value de XML-bewerking heeft een XML- of XMLList-waarde nodig +@msg the XPath expression does not evaluate to a node-set de XPath-expressie levert geen knooppuntenset op +@msg the XPath expression ends early de XPath-expressie eindigt te vroeg +@msg the XPath expression has an unmatched ) de XPath-expressie heeft een ) zonder tegenhanger +@msg the XPath expression has an unmatched ] de XPath-expressie heeft een ] zonder tegenhanger +@msg the XPath expression has an unmatched closing bracket de XPath-expressie heeft een sluithaakje zonder tegenhanger +@msg the XPath expression selects a namespace node, which is not an XML value de XPath-expressie selecteert een naamruimteknooppunt, en dat is geen XML-waarde +@msg the XPath expression selects the root node, which is not an XML value de XPath-expressie selecteert het hoofdknooppunt, en dat is geen XML-waarde +@msg the XPath function {name} needs a node-set de XPath-functie {name} heeft een knooppuntenset nodig +@msg the XPath function {name} takes the wrong number of arguments de XPath-functie {name} krijgt het verkeerde aantal argumenten +@msg the XPath name functions need a node-set de XPath-naamfuncties hebben een knooppuntenset nodig +@msg the XPath prefix {shown} is not bound to a namespace het XPath-voorvoegsel {shown} is niet aan een naamruimte gebonden +@msg the XPath variable ${shown} is not bound de XPath-variabele ${shown} is niet gebonden +@msg the action set ids are not a list de id’s van de actieset zijn geen lijst +@msg the active paint is not a gradient de actieve verf is geen verloop +@msg the argument list is not an object de argumentenlijst is geen object +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it de illustratie van een grafiekontwerp in een grafiekreeks wordt opnieuw vanuit het ontwerp getekend: bewerk het ontwerp om het te wijzigen +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size de tekengebieden passen niet op het canvas: minder tekengebieden, minder tussenruimte of een kleinere grootte +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out de illustratie is te groot voor EPS: wat na 256 MB PostScript komt, is weggelaten +@msg the banner has no width or height de banner heeft geen breedte of hoogte +@msg the bitmaps hold more pixels than one file may de bitmaps bevatten meer pixels dan één bestand mag bevatten +@msg the character's size can't be measured de grootte van het teken kan niet worden gemeten +@msg the code runs past its last instruction de code loopt voorbij de laatste instructie +@msg the compound shape is too complex to expand de samengestelde vorm is te complex om uit te breiden +@msg the container of the listed objects is gone de container van de vermelde objecten is verdwenen +@msg the copy `{user_id}` can't be read as a library de kopie `{user_id}` kan niet als bibliotheek worden gelezen +@msg the copy would leave the canvas de kopie zou buiten het canvas vallen +@msg the curve tolerance must be from 0.01 to 10 points de curvetolerantie moet van 0,01 tot 10 punten zijn +@msg the custom dictionary couldn't be saved to {_1}: {e} het aangepaste woordenboek kon niet worden opgeslagen in {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is het aangepaste woordenboek {_1} kan niet worden gelezen ({why}), dus blijft het zoals het is +@msg the cut cannot be made on this art: {e} de snede kan niet in deze illustratie worden gemaakt: {e} +@msg the data isn't an EMF or WMF picture de gegevens zijn geen EMF- of WMF-afbeelding +@msg the data isn't an EPS or PostScript picture de gegevens zijn geen EPS- of PostScript-afbeelding +@msg the deferred paste is no longer available het uitgestelde plakken is niet meer beschikbaar +@msg the design is too big to draw at every data point of this graph het ontwerp is te groot om bij elk gegevenspunt van deze grafiek te tekenen +@msg the design is too big to draw in every column of this graph het ontwerp is te groot om in elke kolom van deze grafiek te tekenen +@msg the dialog it was opened over is no longer open het dialoogvenster waarboven het is geopend, is niet meer geopend +@msg the dictionary stack overflowed de woordenboekstack is overgelopen +@msg the document already has an SVG filter named {name} het document heeft al een SVG-filter met de naam {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it het document heeft een ander SVG-filter met de naam {name}: gebruik svgFilter.apply, of svgFilter.define met `replace` om het te bewerken +@msg the document has no SVG filter named {name} het document heeft geen SVG-filter met de naam {name} +@msg the document has no SVG filter named {r} het document heeft geen SVG-filter met de naam {r} +@msg the document has no color swatches het document heeft geen kleurstalen +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 de stalen van het document bevatten {_1} kleuren; de documentreductie accepteert er maximaal 255 +@msg the editing data's hash has no place de hash van de bewerkingsgegevens heeft geen plaats +@msg the entities the document declares expand to too much text de entiteiten die het document declareert, worden tot te veel tekst uitgevouwen +@msg the envelope's content has no bounds de inhoud van de omvorming heeft geen grenzen +@msg the envelope's mesh is malformed het net van de omvorming is misvormd +@msg the file holds no `variableSets` element het bestand bevat geen element `variableSets` +@msg the file is larger than {_1} MB het bestand is groter dan {_1} MB +@msg the file is too large het bestand is te groot +@msg the file isn't valid SVG: {e} het bestand is geen geldige SVG: {e} +@msg the file names no plate het bestand noemt geen plaat +@msg the file was updated or encrypted already het bestand is al bijgewerkt of versleuteld +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) het eerste teken van een regel heeft geen teken ervoor om mee te kernen: het beweegt alleen omhoog en omlaag (dy) +@msg the font {_1} is not available het lettertype {_1} is niet beschikbaar +@msg the format has no file extension de indeling heeft geen bestandsextensie +@msg the function's prototype is not an object het prototype van de functie is geen object +@msg the graphic can't be read: {e} de afbeelding kan niet worden gelezen: {e} +@msg the graphic is empty de afbeelding is leeg +@msg the graphic's data is damaged de gegevens van de afbeelding zijn beschadigd +@msg the graphic's document has no artboard het document van de afbeelding heeft geen tekengebied +@msg the grommet inset is too large for the banner de afstand van de zeilringen tot de rand is te groot voor de banner +@msg the guide is hidden or locked de hulplijn is verborgen of vergrendeld +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them de koptekst `{cell}` voor `{name}` zou worden teruggelezen als de variabele `{other}`: geef een van beide een andere naam +@msg the hem is too deep for the banner de zoom is te diep voor de banner +@msg the image `{path}` isn't followed: {r} de afbeelding `{path}` wordt niet gevolgd: {r} +@msg the image has no embedded pixels de afbeelding heeft geen ingesloten pixels +@msg the image is empty de afbeelding is leeg +@msg the image is fully transparent de afbeelding is volledig transparant +@msg the image is not on an artboard; pass rect de afbeelding staat niet op een tekengebied; geef rect op +@msg the image's pixels are missing de pixels van de afbeelding ontbreken +@msg the image's pixels aren't in the document de pixels van de afbeelding staan niet in het document +@msg the image's pixels can't be read: {e} de pixels van de afbeelding kunnen niet worden gelezen: {e} +@msg the image's transparency doesn't fit it de transparantie van de afbeelding past er niet bij +@msg the job would print more than {MAX_PAGES} pages de taak zou meer dan {MAX_PAGES} pagina’s afdrukken +@msg the job would print more than {_1} pages de taak zou meer dan {_1} pagina’s afdrukken +@msg the layers hold no art to mask de lagen bevatten geen illustratie om te maskeren +@msg the layout moved de indeling is verplaatst +@msg the library has a group `{name}` already de bibliotheek heeft al een groep `{name}` +@msg the library has no group `{name}` de bibliotheek heeft geen groep `{name}` +@msg the library has no item `{item}` de bibliotheek heeft geen item `{item}` +@msg the library has no item `{other}` de bibliotheek heeft geen item `{other}` +@msg the library holds {MAX_GROUPS} groups already de bibliotheek bevat al {MAX_GROUPS} groepen +@msg the library holds {MAX_ITEMS} items of this kind already de bibliotheek bevat al {MAX_ITEMS} items van dit soort +@msg the library's file is outside the read roots het bestand van de bibliotheek staat buiten de leesroots +@msg the line is too long, or the paths it passes too many, to check de lijn is te lang, of de paden die hij passeert zijn te talrijk, om te controleren +@msg the method needs an XML value de methode heeft een XML-waarde nodig +@msg the name is empty de naam is leeg +@msg the object has no fill, stroke or effect het object heeft geen vulling, contour of effect +@msg the object has no graphic style (pass `name`) het object heeft geen grafische stijl (geef `name` op) +@msg the objects don't match the plan de objecten komen niet overeen met het plan +@msg the open and permissions passwords must differ het wachtwoord voor openen en het wachtwoord voor machtigingen moeten verschillen +@msg the operand stack overflowed de operandstack is overgelopen +@msg the page has no area de pagina heeft geen oppervlak +@msg the page's type in an unread object's box isn't in its page art de tekst van de pagina in het kader van een ongelezen object zit niet in de pagina-illustratie ervan +@msg the paint behind the proxy is not a gradient de verf achter de proxy is geen verloop +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) de verf is geen verloop in vrije vorm (pas er een toe met paint.editGradient {kind}) +@msg the parameters or body do not form one function de parameters of de body vormen niet één functie +@msg the parent must be a layer or sublayer het bovenliggende item moet een laag of sublaag zijn +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it het geplakte SVG-filter {name} tekent anders dan het filter met die naam in het document, en er is geen vrije naam die past +@msg the path can't be extended het pad kan niet worden verlengd +@msg the path can't be inside one of the objects to attach het pad kan niet in een van de te koppelen objecten zitten +@msg the path has no segments to measure het pad heeft geen segmenten om te meten +@msg the path is empty het pad is leeg +@msg the path is too short to shorten that much het pad is te kort om zoveel in te korten +@msg the paths are too many or too tangled to find where they cross de paden zijn te talrijk of te verward om te vinden waar ze elkaar kruisen +@msg the pen table holds more than 255 pens de pennentabel bevat meer dan 255 pennen +@msg the picture has no objects de afbeelding heeft geen objecten +@msg the plates can't be kept de platen kunnen niet worden behouden +@msg the position table does not match the instructions de positietabel komt niet overeen met de instructies +@msg the program runs too long het programma loopt te lang +@msg the program uses too much memory het programma gebruikt te veel geheugen +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes de tekst van de prompt is langer dan {MAX_PROMPT_BYTES} bytes +@msg the radix must be between 2 and 36 het grondtal moet tussen 2 en 36 liggen +@msg the rectangle must have a width and a height de rechthoek moet een breedte en een hoogte hebben +@msg the rectangle needs a size and must lie on the canvas de rechthoek heeft een grootte nodig en moet op het canvas liggen +@msg the rectangle or line reaches too many paths to check de rechthoek of lijn raakt te veel paden om te controleren +@msg the right side of 'in' is not an object de rechterkant van 'in' is geen object +@msg the right side of 'instanceof' is not a function de rechterkant van 'instanceof' is geen functie +@msg the selected art has no width de geselecteerde illustratie heeft geen breedte +@msg the selected art is nested more than {MAX_DEPTH} levels deep de geselecteerde illustratie is meer dan {MAX_DEPTH} niveaus diep genest +@msg the selected art is too large to measure de geselecteerde illustratie is te groot om te meten +@msg the selection draws nothing de selectie tekent niets +@msg the selection has no area de selectie heeft geen oppervlakte +@msg the selection has no art to export de selectie heeft geen illustratie om te exporteren +@msg the selection has no bounds de selectie heeft geen grenzen +@msg the selection has no graphic style de selectie heeft geen grafische stijl +@msg the selection has no outlines to weed around de selectie heeft geen omtrekken om omheen te wieden +@msg the slice would leave the canvas de slice zou buiten het canvas vallen +@msg the spacing makes more than {MAX_GROMMETS} grommets de tussenruimte levert meer dan {MAX_GROMMETS} zeilringen op +@msg the spot colour couldn't be added de steunkleur kon niet worden toegevoegd +@msg the subpath has no ends to extend (it is closed or a single point) het subpad heeft geen uiteinden om te verlengen (het is gesloten of een enkel punt) +@msg the symbols are nested too deeply to expand their instances de symbolen zijn te diep genest om hun instanties uit te breiden +@msg the text file is too large to place as type (at most {_1} MB) het tekstbestand is te groot om als tekst te plaatsen (maximaal {_1} MB) +@msg the text is empty de tekst is leeg +@msg the text is over {_1} MB de tekst is groter dan {_1} MB +@msg the top object must be a path het bovenste object moet een pad zijn +@msg the trace was cancelled de tracering is geannuleerd +@msg the trailer has no size de trailer heeft geen grootte +@msg the type object is flattened to nothing het tekstobject is tot niets afgevlakt +@msg the value bind was called on is not a function de waarde waarop bind is aangeroepen, is geen functie +@msg the value is not a constructor de waarde is geen constructor +@msg the value is not a function de waarde is geen functie +@msg the value is not a valid XML name de waarde is geen geldige XML-naam +@msg the value is not an XML attribute name de waarde is geen XML-attribuutnaam +@msg the value is not an XML name de waarde is geen XML-naam +@msg the value of axis `{tag}` must be a number or null de waarde van as `{tag}` moet een getal of null zijn +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets de variabelenbibliotheek declareert meer dan {MAX_LIBRARY_DATASETS} gegevenssets +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities de variabelenbibliotheek declareert meer dan {MAX_LIBRARY_ENTITIES} entiteiten +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables de variabelenbibliotheek declareert meer dan {MAX_LIBRARY_VARIABLES} variabelen +@msg the variable library declares no variables de variabelenbibliotheek declareert geen variabelen +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes de variabelenbibliotheek is groter dan {MAX_LIBRARY_BYTES} bytes +@msg the variable library is not UTF-8 de variabelenbibliotheek is geen UTF-8 +@msg the variable library is not well-formed XML: {e} de variabelenbibliotheek is geen welgevormde XML: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep de variabelenbibliotheek nest elementen meer dan {MAX_LIBRARY_NESTING} niveaus diep +@msg the variable library's document type declaration holds something other than entities of plain text de documenttypedeclaratie van de variabelenbibliotheek bevat iets anders dan entiteiten van platte tekst +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes de entiteiten van de variabelenbibliotheek worden tot meer dan {MAX_LIBRARY_BYTES} bytes uitgevouwen +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines de wiedlijnen kruisen de omtrekken van de illustratie te vaak om te meten; gebruik minder lijnen +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap de wiedlijnen lopen te dicht langs te veel omtreksegmenten om te meten; gebruik minder lijnen of een kleinere tussenruimte +@msg the weeding lines would be more than {max_pieces} pieces de wiedlijnen zouden uit meer dan {max_pieces} stukken bestaan +@msg the window has no dock het venster heeft geen dock +@msg the written PDF has no catalog de geschreven PDF heeft geen catalogus +@msg the written PDF has no cross-reference table de geschreven PDF heeft geen kruisverwijzingstabel +@msg the written PDF's metadata can't be edited de metagegevens van de geschreven PDF kunnen niet worden bewerkt +@msg the {_1} plate lies outside the file de plaat {_1} ligt buiten het bestand +@msg there are no colour swatches to draw er zijn geen kleurstalen om te tekenen +@msg there is already a design named `{name}` er is al een ontwerp met de naam `{name}` +@msg there is already a design named `{to}` er is al een ontwerp met de naam `{to}` +@msg there is already a toolbar named `{name}` er is al een werkbalk met de naam `{name}` +@msg there is already a toolbar named `{to}` er is al een werkbalk met de naam `{to}` +@msg there is no banner to mark er is geen banner om te markeren +@msg there is no library: create one first er is geen bibliotheek: maak er eerst een +@msg these paths are too degenerate for Shaper deze paden zijn te gedegenereerd voor Shaper +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that dit is een binair DXF-bestand, dat niet kan worden gelezen: sla het vanuit de CAD-app op als ASCII-DXF en open dat bestand +@msg this warp's points can't be edited: use Reset with Mesh first de punten van deze verdraaiing kunnen niet worden bewerkt: gebruik eerst Herstellen met mesh +@msg thumbnail: no page {v} (the {what} has {count}) miniatuur: geen pagina {v} ({what} heeft er {count}) +@msg tileRange of {what}: {e} tileRange van {what}: {e} +@msg title can't be empty title mag niet leeg zijn +@msg to (the new name) is required to (de nieuwe naam) is vereist +@msg to must be fill or stroke to moet fill of stroke zijn +@msg to must be smooth or corner to moet smooth of corner zijn +@msg toExponential() digits must be between 0 and 20 het aantal cijfers van toExponential() moet tussen 0 en 20 liggen +@msg toFixed() digits must be between 0 and 20 het aantal cijfers van toFixed() moet tussen 0 en 20 liggen +@msg toISOString is not a function toISOString is geen functie +@msg toPrecision() argument must be between 1 and 21 het argument van toPrecision() moet tussen 1 en 21 liggen +@msg toSource: a getter cannot be written as source toSource: een getter kan niet als broncode worden geschreven +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: een array met eigenschappen naast zijn elementen, een niet-opsombare eigenschap of een ander prototype kan niet als broncode worden geschreven +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: een object met een niet-opsombare eigenschap, of waarvan het prototype niet Object.prototype is, kan niet als broncode worden geschreven +@msg toSource: the source is too long toSource: de broncode is te lang +@msg toSource: the value contains itself toSource: de waarde bevat zichzelf +@msg toSource: the value has too many entries toSource: de waarde heeft te veel items +@msg toSource: the value is nested too deep toSource: de waarde is te diep genest +@msg toString is not a function toString is geen functie +@msg too large for a zip archive: package to a folder te groot voor een zip-archief: verpak naar een map +@msg too many Shaper edits te veel Shaper-bewerkingen +@msg too many `points` (at most 100000) te veel `points` (maximaal 100000) +@msg too many arguments te veel argumenten +@msg too many characters: at most {MAX_CHARS} te veel tekens: maximaal {MAX_CHARS} +@msg too many dots for this size: lower the frequency te veel punten voor deze grootte: verlaag de frequentie +@msg too many filters over one file te veel filters over één bestand +@msg too many overlapping paths for Shaper te veel overlappende paden voor Shaper +@msg too many quantifiers te veel kwantoren +@msg too many saved clips te veel opgeslagen knippaden +@msg too many saved graphics states te veel opgeslagen grafische toestanden +@msg toolbar (a toolbar's name) must be a string toolbar (de naam van een werkbalk) moet een string zijn +@msg toolbar name can't be empty de naam van een werkbalk mag niet leeg zijn +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (de standaard) of "omit" +@msg turn on at least one side schakel minstens één zijde in +@msg two saved selections would be named `{_1}` twee opgeslagen selecties zouden de naam `{_1}` krijgen +@msg type dimension cannot be represented with this text transform de tekstmaatlijn kan niet worden weergegeven met deze teksttransformatie +@msg type dimension must be finite de tekstmaatlijn moet eindig zijn +@msg unexpected resources onverwachte resources +@msg unknown CMYK profile `{name}` onbekend CMYK-profiel `{name}` +@msg unknown File Info field `{k}` onbekend veld `{k}` van Bestandsinfo +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) onbekende OpenType-functie `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; zet er - voor om uit te schakelen; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom en aalt accepteren het nummer van een alternatief als `tag=N` met N van 0 tot {_1}, zoals `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) onbekende OpenType-functie `{t}` in featureChanges (de tags die `features` accepteert) +@msg unknown PDF preset `{name}` (presets: {_1}) onbekende PDF-voorinstelling `{name}` (voorinstellingen: {_1}) +@msg unknown Warp style onbekende warpstijl +@msg unknown alignToPath `{a}` onbekende alignToPath `{a}` +@msg unknown arrowhead {v} onbekende pijlpunt {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) onbekende sleutel `{k}` van artboardLayout (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` onbekend kenmerk `{attribute}` +@msg unknown attribute `{k}` onbekend kenmerk `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) onbekende as `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` onbekende vermengingsmodus `{b}` +@msg unknown blend mode {b} onbekende vermengingsmodus {b} +@msg unknown brush type `{o}` onbekend penseeltype `{o}` +@msg unknown category `{c}` onbekende categorie `{c}` +@msg unknown category `{c}` ({_1}) onbekende categorie `{c}` ({_1}) +@msg unknown character `{c}` onbekend teken `{c}` +@msg unknown character style number field onbekend getalveld van een tekenstijl +@msg unknown dither `{d}` (diffusion, pattern, noise or none) onbekende dither `{d}` (diffusion, pattern, noise of none) +@msg unknown effect `{e}` onbekend effect `{e}` +@msg unknown end {e} onbekend uiteinde {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) onbekende flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution of een opgeslagen voorinstelling: zie flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) onbekende indeling `{f}` (vcswatches, gpl, ase of css) +@msg unknown from {f} onbekende from {f} +@msg unknown graph type `{t}` onbekend grafiektype `{t}` +@msg unknown grid colours `{name}` (one of {_1}) onbekende rasterkleuren `{name}` (een van {_1}) +@msg unknown gridType `{t}` onbekende gridType `{t}` +@msg unknown highlight `{v}` onbekende markering `{v}` +@msg unknown intent `{i}` onbekende intentie `{i}` +@msg unknown issue kind {id} onbekend soort probleem {id} +@msg unknown issue kind {v} onbekend soort probleem {v} +@msg unknown knockout state {k} onbekende knockoutstatus {k} +@msg unknown language `{language}` (one of {_1}) onbekende taal `{language}` (een van {_1}) +@msg unknown language `{l}` (one of {_1}) onbekende taal `{l}` (een van {_1}) +@msg unknown method `{m}` ({_1}) onbekende methode `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) onbekende modus `{other}` (gray, rgb, hsb, lab, cmyk of web) +@msg unknown mode `{other}` (mono|cmyk) onbekende modus `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) onbekende modus `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) onbekende modus `{other}` (set, add, subtract) +@msg unknown mode `{o}` onbekende modus `{o}` +@msg unknown mode {m} onbekende modus {m} +@msg unknown model `{other}` onbekend model `{other}` +@msg unknown model `{other}` (keep, new or document) onbekend model `{other}` (keep, new of document) +@msg unknown op {o} onbekende op {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back onbekende volgorde `{o}`: toggle, front, forward, backward of back +@msg unknown overprints `{v}` (preserve, simulate or discard) onbekende overprints `{v}` (preserve, simulate of discard) +@msg unknown panel onbekend paneel +@msg unknown panel `{panel}` onbekend paneel `{panel}` +@msg unknown parameter `{key}` onbekende parameter `{key}` +@msg unknown pieLegend `{v}` (none, standard or wedges) onbekende pieLegend `{v}` (none, standard of wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) onbekende piePosition `{v}` (even, ratio of stacked) +@msg unknown pieSort `{v}` (none, all or first) onbekende pieSort `{v}` (none, all of first) +@msg unknown plate `{n}` onbekende plaat `{n}` +@msg unknown preference `{k}` onbekende voorkeur `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) onbekend profiel {id} (zie stroke.widthProfile.list) +@msg unknown proof target `{t}` onbekend proefdoel `{t}` +@msg unknown quotes key `{k}` (double, single) onbekende sleutel `{k}` van quotes (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) onbekende reductie `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform of document) +@msg unknown resolution `{v}` onbekende resolutie `{v}` +@msg unknown rule `{rule}` ({_1}) onbekende regel `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) onbekende regel `{r}` (zie color.harmony) +@msg unknown setting `{k}` onbekende instelling `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) onbekende vorm `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) onbekende spacing `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) onbekende tileType `{t}` ({_1}) +@msg unknown units onbekende eenheden +@msg unknown units `{u}` onbekende eenheden `{u}` +@msg unknown value axis `{v}` (left, right or both) onbekende waardeas `{v}` (left, right of both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) onbekende variatie `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` onbekende warpstijl `{s}` +@msg unknown {key} `{v}` (none, short or full) onbekende {key} `{v}` (none, short of full) +@msg unknown {key} profile `{n}` onbekend {key}-profiel `{n}` +@msg unknown {k} key `{key}` (size, position) onbekende sleutel `{key}` van {k} (size, position) +@msg unmatched ')' ')' zonder tegenhanger +@msg unreadable object onleesbaar object +@msg unsafe destination file name onveilige naam voor het doelbestand +@msg unterminated character class niet-afgesloten tekenklasse +@msg unterminated group niet-afgesloten groep +@msg use paths or compound paths gebruik paden of samengestelde paden +@msg useArtboards must be true or false, not {v} useArtboards moet true of false zijn, niet {v} +@msg value for `{var}` must match its variable kind de waarde voor `{var}` moet bij het soort variabele passen +@msg variable `{_1}` is already defined variabele `{_1}` is al gedefinieerd +@msg variable `{name}` has no `trait` variabele `{name}` heeft geen `trait` +@msg variable `{name}` is already defined variabele `{name}` is al gedefinieerd +@msg variable `{name}` is declared twice variabele `{name}` is twee keer gedeclareerd +@msg variable `{to}` already exists variabele `{to}` bestaat al +@msg view is required view is vereist +@msg view must be one of {_1} view moet een van {_1} zijn +@msg warpStyle can only edit an applied Warp effect warpStyle kan alleen een toegepast warpeffect bewerken +@msg width and height must be more than 0 and at most {limit} width en height moeten meer dan 0 en maximaal {limit} zijn +@msg width factors must be finite breedtefactoren moeten eindig zijn +@msg width must be positive width moet positief zijn +@msg width out of range breedte buiten het bereik +@msg width/height must be > 0 width/height moeten > 0 zijn +@msg with no hem and no grommets there is nothing to mark zonder zoom en zonder zeilringen is er niets om te markeren +@msg wordGap and lineGap need area type in the selection wordGap en lineGap hebben vlaktekst in de selectie nodig +@msg x and y must be finite numbers x en y moeten eindige getallen zijn +@msg x and y must be given together x en y moeten samen worden opgegeven +@msg xpath needs its variables as an object xpath heeft zijn variabelen als object nodig +@msg xpath needs the expression as a string xpath heeft de expressie als string nodig +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone heeft een anker nodig: dock naast een paneel met anchor, of in een nieuwe dockkolom met side (Start of End) +@msg zoom must be positive zoom moet positief zijn +@msg {C}: no effect at index {list} {C}: geen effect op index {list} +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} is beveiligd en blijft bestaan +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} is beveiligd: sla de instellingen onder een andere naam op +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}, en de instellingen en cache ervan kunnen niet van {_1} naar {_2} worden teruggezet: {e} +@msg {LABEL} produced an empty result {LABEL} heeft een leeg resultaat opgeleverd +@msg {_1} MB of document, at most {_2} MB {_1} MB aan document, maximaal {_2} MB +@msg {_1} MB of images, at most {_2} MB {_1} MB aan afbeeldingen, maximaal {_2} MB +@msg {_1} bytes of RGBA data for a {width} × {height} image {_1} bytes aan RGBA-gegevens voor een afbeelding van {width} × {height} +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} kan niet worden gelezen, of is gewijzigd sinds het is gelezen: koppel het eerst opnieuw of werk het eerst bij +@msg {_1} can't go into {into} {_1} kan niet in {into} worden geplaatst +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} kan afdrukken niet tot lage resolutie beperken: kies PDF 1.4 of later, of sta afdrukken in hoge resolutie toe +@msg {_1} files can't be opened yet {_1}-bestanden kunnen nog niet worden geopend +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1}-bestanden kunnen niet met een wachtwoord worden beveiligd: verwijder de wachtwoorden of kies geen standaard +@msg {_1} files can't be {_2} {_1}-bestanden kunnen niet {_2} zijn +@msg {_1} files can't carry editing data: turn off preserveEditing {_1}-bestanden kunnen geen bewerkingsgegevens bevatten: schakel preserveEditing uit +@msg {_1} files can't have PDF layers: turn off createLayers {_1}-bestanden kunnen geen PDF-lagen hebben: schakel createLayers uit +@msg {_1} files need a creation date, and this platform has no clock to give one {_1}-bestanden hebben een aanmaakdatum nodig, en dit platform heeft geen klok om er een te geven +@msg {_1} is not a folder: {e} {_1} is geen map: {e} +@msg {_1} isn't a path {_1} is geen pad +@msg {_1} options: {e} opties voor {_1}: {e} +@msg {_1} paints nothing to rasterize {_1} verft niets om te rasteriseren +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) wachtwoorden voor {_1} mogen maximaal {MAX_LEGACY_PASSWORD} ASCII-tekens hebben (PDF 1.7 en later accepteren alle tekens) +@msg {_1} with linked images or an image map writes several files: use document.export {_1} met gekoppelde afbeeldingen of een imagemap schrijft meerdere bestanden: gebruik document.export +@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1} schrijft hier één bestand per tekengebied: gebruik document.export, of noem één tekengebied +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} pixels is te groot voor {_3} (maximaal {max} pixels per zijde): verlaag de schaal +@msg {_1}: belongs to another account {_1}: hoort bij een ander account +@msg {_1}: not a folder of its own {_1}: geen eigen map +@msg {_1}: not a plain file {_1}: geen gewoon bestand +@msg {_1}: {_2} and {_3} are in the way {_1}: {_2} en {_3} staan in de weg +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder {doc} heeft geen map +@msg {file} isn't linked {file} is niet gekoppeld +@msg {file} names more than one linked JavaScript file {file} noemt meer dan één gekoppeld JavaScript-bestand +@msg {folder} is not a package folder {folder} is geen pakketmap +@msg {folder}: more than {max} files and folders {folder}: meer dan {max} bestanden en mappen +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} is geen Live Paint-groep +@msg {id} is not a slice {id} is geen slice +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} moet 0–{MAX_ADD_AROUND} pt zijn +@msg {key} must be [x,y] {key} moet [x,y] zijn +@msg {key} must be a percentage {min}..100 {key} moet een percentage {min}..100 zijn +@msg {key} must be a positive number, not {n} {key} moet een positief getal zijn, niet {n} +@msg {key} must be a positive number, not {v} {key} moet een positief getal zijn, niet {v} +@msg {key} must be one of {_1} {key} moet een van {_1} zijn +@msg {key} must be {_1}, got {s} {key} moet {_1} zijn, kreeg {s} +@msg {key} must name a floating panel {key} moet een zwevend paneel noemen +@msg {key} must name a panel in another group {key} moet een paneel in een andere groep noemen +@msg {key} must name a panel that shows {key} moet een paneel noemen dat wordt getoond +@msg {key} must name a registered panel {key} moet een geregistreerd paneel noemen +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind}-bestanden hebben geen transparantie, en het bestand zou {what} bevatten: vlak het eerst af (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind}-afbeeldingen kunnen niet worden geopend (zie document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}` is geen van de keuzes +@msg {k} has control characters{_1} {k} bevat besturingstekens{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k} is te lang (maximaal {MAX_TEXT} tekens) +@msg {k} must be > 0 {k} moet > 0 zijn +@msg {k} must be a number (pt) or a length such as "210 mm" {k} moet een getal (pt) of een lengte zoals "210 mm" zijn +@msg {k} must be a number from {min} to {max} {k} moet een getal van {min} tot {max} zijn +@msg {k} must be a number or "auto" {k} moet een getal of "auto" zijn +@msg {k} must be a string {k} moet een string zijn +@msg {k} must be text {k} moet tekst zijn +@msg {k} must be true or false {k} moet true of false zijn +@msg {k} must be {{size?, position?}} {k} moet {{size?, position?}} zijn +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: het pad van een aangepaste pijlpunt heeft een oppervlak om te vullen nodig en maximaal {most} ankers en {most} subpaden, elk anker binnen {far} kopeenheden van de punt +@msg {k}: bad custom arrowhead path: {e} {k}: ongeldig pad van een aangepaste pijlpunt: {e} +@msg {k}: bad custom arrowhead: {e} {k}: ongeldige aangepaste pijlpunt: {e} +@msg {k}: can't read `{s}` as a length {k}: kan `{s}` niet als lengte lezen +@msg {k}: custom must be a name {k}: custom moet een naam zijn +@msg {label} must be a number at most {max} {label} moet een getal van maximaal {max} zijn +@msg {label} must be a number from -1296 to 1296 {label} moet een getal van -1296 tot 1296 zijn +@msg {label} must be a number from 1 to 10000 {label} moet een getal van 1 tot 10000 zijn +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} is ingebouwd en kan niet worden verwijderd +@msg {name} must be a number {name} moet een getal zijn +@msg {name}: CRC or size mismatch {name}: CRC of grootte komt niet overeen +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: alleen de ingesloten voorvertoning van dit Affinity-bestand kon worden gelezen; gebruik Bestand › Openen om die met de bijbehorende waarschuwing te bekijken, of exporteer SVG of PDF vanuit Affinity voordat je plaatst +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; de ingesloten voorvertoning ervan kan ook niet worden gelezen ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy accepteert een DCS-bestand alleen wanneer de platen erin zitten; koppel het opnieuw op de plek waar de plaatbestanden staan +@msg {path}: larger than {_1} MB {path}: groter dan {_1} MB +@msg {saving} failed: {e} {saving} is mislukt: {e} +@msg {shown} is not an XPath 1.0 function {shown} is geen XPath 1.0-functie +@msg {std} files are CMYK, and the file would have colours in {_1} {std}-bestanden zijn CMYK, en het bestand zou kleuren in {_1} bevatten +@msg {std} files embed their fonts, and the file would have one that isn't {std}-bestanden sluiten hun lettertypen in, en het bestand zou er een bevatten dat niet is ingesloten +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} is het bestand waaruit dit document is gelezen, en bij het lezen zijn dingen weggelaten ({what}): door eroverheen te schrijven zouden die voorgoed verloren gaan. Schrijf in plaats daarvan een ander bestand, of geef acknowledgeLoss: true op om het toch te vervangen +@msg {ty} brushes need selected art (or params.{k}) {ty}-penselen hebben een geselecteerde illustratie nodig (of params.{k}) +@msg {v} is not an asset id {v} is geen asset-id +@msg {v} names no layer or sublayer {v} noemt geen laag of sublaag +@msg {what} can't be intertwined {what} kan niet worden verstrengeld +@msg {what} doesn't fit the paper with its bleed and marks {what} past niet op het papier met de afloop en de tekens ervan +@msg {what} is not a function {what} is geen functie +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} zou meer dan {MAX_COPIES_PER_SHEET} exemplaren op een vel afdrukken: schaal het omhoog +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} zou op meer dan {MAX_TILES} tegels worden afgedrukt: schaal het omlaag +@msg {what} {i} out of range {what} {i} buiten het bereik + Ruler Origin Lineaaloorsprong Reset Ruler Origin Lineaaloorsprong herstellen Column Width: Kolombreedte: diff --git a/docs/localization-parity.md b/docs/localization-parity.md index cd8dc33f30..b4cf0013bf 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -51,7 +51,7 @@ plus review. Native-speaker review is human work and isn't in the hours. |---|---|---|---| | Traditional Chinese (Taiwan) | `zh-hant` | 4,892 rows, 790 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted (80% overall) | partial | | Italian | `it` | 4,011 / 4,020 | full | -| Dutch | `nl` | 7,299 rows, 1,609 of them `@msg` and 47 `@msgplural` (counted 2026-11-13 on `main` at `9a1a158a3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for German; one/other plurals | full | +| Dutch | `nl` | 3,447 `@msg` and 48 `@msgplural` rows (counted 2026-11-13): in `COMPLETE_MESSAGES`, and the untranslated report lists 41 missing messages for it; one/other plurals | full | | Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full | | Russian | `ru` | 4,009 / 4,020 | full | | Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f3707717e6..7cbfdea2d3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -885,6 +885,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). + - The Dutch catalog (`crates/ui-egui/src/i18n/nl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,447 `@msg` rows in all. The report lists 41 Dutch messages: the composite font errors, the justification spacing error and the `connect` end error, which open #7211, #7116 and #7330 translate, and three unknown-parameter and range errors (``unknown parameter `{within}{key}`; {why}``, ``unknown parameter `{within}settings.{format}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`), which open #7353 rewrites so that a catalog row can match them. The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From 7b475b46b0b47663a9828365f1c16f6f06498dda Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:05:00 -0800 Subject: [PATCH 08/35] .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 1f6332c0a4cda24ae9887ec3e3a2b79bba21d83e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:17:21 -0800 Subject: [PATCH 09/35] Live Paint: Expand keeps the faces and edges that draw Expand kept a face only when its topmost fill had paint, and an edge only when its topmost stroke had paint and a weight. A face or edge drawn by a lower row under an unpainted top row was removed, and one whose only painted row was hidden was kept as a visible path. Expand now keeps a face or edge when it is shown, its opacity is above 0, and any fill or stroke row is shown, has paint and an opacity above 0 (a stroke also a weight above 0), with all of its rows. The rows are checked with the new Appearance::has_drawn_fill and has_drawn_stroke. Merge carried a face's topmost fill paint by the same topmost-row test. It now carries every row of each face that draws by this rule, and of each edge, pins their unplaced gradients to the old outline as repaint_from does, and leaves the strokes' pen pressure, tilt, bearing and rotation behind, since the rebuilt parts are other paths. Docs: livePaint.expand params text, docs/mcp.md, and a line in docs/target-app-parity.md, which also drops a repeated Live Paint Selection marquee line. --- crates/doc/src/appearance.rs | 10 ++ crates/doc/src/appearance/tests.rs | 22 +++ crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 45 +++-- crates/engine/src/tests/build.rs | 158 ++++++++++++++++++ docs/mcp.md | 6 + docs/target-app-parity.md | 2 +- 7 files changed, 229 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index ba0f5fc013..3bfba32f6a 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -271,6 +271,16 @@ impl Appearance { _ => None, }) } + /// Does any fill draw: one that is shown, has paint and has an opacity above 0, wherever it + /// sits in the stack? + pub fn has_drawn_fill(&self) -> bool { + self.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() && f.opacity > 0.0)) + } + /// Does any stroke draw: one that is shown, has paint, a weight above 0 and an opacity above + /// 0, wherever it sits in the stack? + pub fn has_drawn_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.width > 0.0 && s.opacity > 0.0) + } /// Largest distance the painted area can extend beyond any geometry (see /// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`]. pub fn outset(&self) -> f64 { diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 22e5659051..cb5f8fed66 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -79,6 +79,28 @@ fn basic_means_one_plain_fill_and_stroke() { assert!(!e.is_basic(), "an effect"); } +#[test] +fn drawn_fills_and_strokes_count_every_row() { + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w)); + let black = || Paint::solid(Color::BLACK); + assert!(!Appearance::default().has_drawn_fill() && !Appearance::default().has_drawn_stroke()); + // A painted lower row draws under an unpainted top row. + let a = Appearance { items: vec![fill(black()), stroke(black(), 2.0), fill(Paint::None), stroke(Paint::None, 1.0)], ..Appearance::default() }; + assert!(a.has_drawn_fill() && a.has_drawn_stroke()); + // A hidden painted row, a zero opacity or a zero weight draws nothing. + let mut b = Appearance { items: vec![fill(black()), stroke(black(), 2.0)], ..Appearance::default() }; + b.fill_mut().unwrap().visible = false; + b.stroke_mut().unwrap().visible = false; + assert!(!b.has_drawn_fill() && !b.has_drawn_stroke()); + let mut c = Appearance::default_art(); + c.fill_mut().unwrap().opacity = 0.0; + c.stroke_mut().unwrap().opacity = 0.0; + assert!(!c.has_drawn_fill() && !c.has_drawn_stroke()); + let d = Appearance::basic(Paint::None, black(), 0.0); + assert!(!d.has_drawn_fill() && !d.has_drawn_stroke()); +} + #[test] fn set_creates_missing() { let mut a = Appearance::default(); diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 3fc5953619..06ec06e348 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Live Paint"], None, - "{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of their painted faces and edges → {ids}", + "{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of the faces and edges that draw, with all their fill and stroke rows: a shown face or edge with an opacity above 0 and a shown fill or stroke row that has paint and an opacity above 0 (a stroke also a weight above 0), whichever row is on top → {ids}", has_selection_or_ids, lp_expand ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index fb9e6c32ac..04c6a3fb1b 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ - Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, - Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, - json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect, + Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json, + number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -84,6 +84,19 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } +/// The appearance of face or edge `n` for the part Merge makes again in its place, another path: +/// gradients fitted to `n`'s outline keep that fit, and the strokes' pen pressure, tilt, bearing +/// and rotation stay behind. +fn carried_appearance(n: &Node) -> Appearance { + let mut app = (*n.appearance).clone().without_pressure(); + if app.has_unplaced_gradient() + && let Some(bounds) = n.geometric_bounds() + { + app.pin_gradients(bounds); + } + app +} + /// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its /// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which /// are merged; returns (group, faces, edges). @@ -95,18 +108,16 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; check_live_paint_takes(d, roots)?; let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, fill) and (point on edge, stroke). - let keep_faces: Vec<(Point, Paint)> = b::faces(&old) - .iter() - .filter(|f| !f.appearance.fill_paint().is_none()) - .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) - .collect(); + // Paint to carry over: (point inside face, appearance) for faces that draw, and (point on + // edge, appearance) for every edge. + let keep_faces: Vec<(Point, Appearance)> = + b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, carried_appearance(f)))).collect(); let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) .iter() .filter_map(|e| { let pd = e.path_data()?; let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), carried_appearance(e))) }) .collect(); let mut sources = source_nodes(&old); @@ -126,12 +137,12 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result *ch = children; } g.extras_mut().live_paint_gaps = stored_gap_options(options); - for (pt, paint) in keep_faces { + for (pt, app) in keep_faces { if let Some(fid) = face_at(&g, pt).map(|f| f.id) && let Some(ch) = g.children_mut() && let Some(f) = ch.iter_mut().find(|c| c.id == fid) { - Arc::make_mut(f).appearance.set_fill(paint); + Arc::make_mut(f).appearance = app.into(); } } for (pt, app) in keep_edges { @@ -191,6 +202,13 @@ pub(super) fn lp_release(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids) })) } +/// Does face or edge `n` draw on the canvas: it is shown, its opacity is above 0, and a fill or +/// stroke row of its appearance draws (see [`Appearance::has_drawn_fill`] and +/// [`Appearance::has_drawn_stroke`])? +fn draws(n: &Node) -> bool { + n.visible && n.opacity > 0.0 && (n.appearance.has_drawn_fill() || n.appearance.has_drawn_stroke()) +} + pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result { let targets = live_targets(s, p)?; s.edit("Expand Live Paint", |d, sel| { @@ -205,8 +223,7 @@ pub(super) fn lp_expand(s: &mut Session, p: &Value) -> Result { if let Some(ch) = n.children_mut() { ch.retain(|c| match c.name.as_deref() { Some(SOURCES_NAME) => false, - Some(FACE_NAME) => !c.appearance.fill_paint().is_none(), - Some(EDGE_NAME) => !c.appearance.stroke_paint().is_none() && c.appearance.stroke_width() > 0.0, + Some(FACE_NAME | EDGE_NAME) => draws(c), _ => true, }); } diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 1c3ad5d8b4..0e6bf5ad8d 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2947,6 +2947,164 @@ fn live_paint_release_and_expand() { assert!((area(&s, a) - 10000.0).abs() < 1.0); } +/// Set the appearance rows of the face of Live Paint group `g` at `face` (or the edge nearest +/// `edge`) to `items`, and give it `opacity`; returns that child's id. +fn set_live_part(s: &mut Session, g: NodeId, face: Option<(f64, f64)>, edge: Option<(f64, f64)>, items: Vec, opacity: f32) -> NodeId { + use vectorcraft_tools::builder::{edge_near, face_at}; + let n = node(s, g); + let part = match (face, edge) { + (Some((x, y)), _) => face_at(&n, Point::new(x, y)), + (_, Some((x, y))) => edge_near(&n, Point::new(x, y), 0.5), + _ => None, + }; + let id = part.unwrap().id; + s.edit("t", |d, _| { + let c = d.node_mut(id).unwrap(); + c.appearance.items = items; + c.opacity = opacity; + Ok(()) + }) + .unwrap(); + id +} + +/// Expand keeps a face or edge when a fill or stroke row draws it, whatever row is on top: a +/// painted lower row under an unpainted top row is kept, and a part whose only painted row is +/// hidden, transparent or 0 pt wide, or whose own opacity is 0, is dropped. +#[test] +fn live_paint_expand_keeps_the_faces_and_edges_that_draw() { + use vectorcraft_doc::{FillLayer, StrokeLayer}; + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w)); + let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let hidden = |mut i: AppearanceItem| { + match &mut i { + AppearanceItem::Fill(f) => f.visible = false, + AppearanceItem::Stroke(st) => st.visible = false, + } + i + }; + let clear = |mut i: AppearanceItem| { + match &mut i { + AppearanceItem::Fill(f) => f.opacity = 0.0, + AppearanceItem::Stroke(st) => st.opacity = 0.0, + } + i + }; + let mut s = session(); + let g = live(&mut s); + let lower_fill = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), fill(Paint::None)], 1.0); + let hidden_fill = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red()))], 1.0); + let clear_fill = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![clear(fill(red()))], 1.0); + let lower_stroke = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 2.0), stroke(Paint::None, 1.0)], 1.0); + let hidden_stroke = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![hidden(stroke(red(), 2.0))], 1.0); + let thin_stroke = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0); + let clear_stroke = set_live_part(&mut s, g, None, Some((50.0, 50.0)), vec![clear(stroke(red(), 2.0))], 1.0); + let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0); + let parts = [lower_fill, hidden_fill, clear_fill, lower_stroke, hidden_stroke, thin_stroke, clear_stroke, clear_edge]; + assert_eq!(parts.iter().collect::>().len(), parts.len()); + let before = node(&s, g); + s.execute("livePaint.expand", &json!({})).unwrap(); + let n = node(&s, g); + let kept: Vec = n.children().unwrap().iter().map(|c| c.id).collect(); + let has = |id: NodeId| kept.contains(&id); + assert!(has(lower_fill) && has(lower_stroke), "a painted lower row draws: {kept:?}"); + for id in [hidden_fill, clear_fill, hidden_stroke, thin_stroke, clear_stroke, clear_edge] { + assert!(!has(id), "{id:?} draws nothing: {kept:?}"); + } + // The kept parts keep every row. + let rows = |n: &Node, id: NodeId| n.children().unwrap().iter().find(|c| c.id == id).unwrap().appearance.items.clone(); + assert_eq!(rows(&n, lower_fill), rows(&before, lower_fill)); + assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke)); +} + +/// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a +/// face that draws nothing. +#[test] +fn live_paint_merge_carries_the_faces_that_draw() { + use vectorcraft_doc::FillLayer; + use vectorcraft_tools::builder::face_at; + let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p)); + let green = || Paint::solid(Color::rgb(0.0, 1.0, 0.0)); + let mut s = session(); + let g = live(&mut s); + let rows = vec![fill(green()), fill(Paint::None)]; + set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows.clone(), 1.0); + let mut only_hidden = fill(green()); + if let AppearanceItem::Fill(f) = &mut only_hidden { + f.visible = false; + } + set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![only_hidden], 1.0); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + assert_eq!(face_at(&n, Point::new(25.0, 50.0)).unwrap().appearance.items, rows, "the drawn lower fill is carried"); + let b = face_at(&n, Point::new(125.0, 50.0)).unwrap(); + assert_eq!(b.appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), "the face takes its source's fill again"); + assert!(b.appearance.has_drawn_fill()); +} + +/// Merge rebuilds the faces and edges as other paths, so the appearance it carries to them leaves +/// the strokes' pen pressure behind and keeps the rest. +#[test] +fn live_paint_merge_leaves_pen_pressure_behind() { + use vectorcraft_doc::{FillLayer, PressureProfile, StrokeLayer}; + use vectorcraft_tools::builder::{edge_near, face_at}; + let pressed = || { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); + st.pressure = PressureProfile::from_samples([(0.0, 0.2), (0.5, 1.0), (1.0, 0.2)]); + assert!(st.pressure.is_some()); + st + }; + let green = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)))); + let mut s = session(); + let g = live(&mut s); + set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![green.clone(), AppearanceItem::Stroke(pressed())], 1.0); + set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(pressed())], 1.0); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + let mut plain = pressed(); + plain.pressure = None; + let face = &face_at(&n, Point::new(75.0, 50.0)).unwrap().appearance.items; + assert_eq!(face, &vec![green, AppearanceItem::Stroke(plain.clone())]); + assert_eq!(edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap().appearance.items, vec![AppearanceItem::Stroke(plain)]); +} + +/// The gradients Merge carries to the rebuilt faces and edges keep their fit on the old outline, +/// lower rows included. +#[test] +fn live_paint_merge_keeps_the_fit_of_carried_gradients() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{FillLayer, StrokeLayer}; + use vectorcraft_tools::builder::{edge_near, face_at}; + let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)]; + let unplaced = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + let gradient = || Paint::Gradient(Box::new(unplaced.clone())); + let mut s = session(); + let g = live(&mut s); + let rows = vec![AppearanceItem::Fill(FillLayer::new(gradient())), AppearanceItem::Fill(FillLayer::new(Paint::None))]; + let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, rows, 1.0); + let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![AppearanceItem::Stroke(StrokeLayer::new(gradient(), 2.0))], 1.0); + let before = node(&s, g); + let old = |id: NodeId| before.children().unwrap().iter().find(|c| c.id == id).unwrap().clone(); + let (old_face, old_edge) = (old(face), old(edge)); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + let geom = |items: &[AppearanceItem]| match items[0].paint() { + Paint::Gradient(gp) => gp.geom, + p => panic!("a gradient, not {p:?}"), + }; + let face_bounds = old_face.geometric_bounds().unwrap(); + let new_face = face_at(&n, Point::new(25.0, 50.0)).unwrap(); + assert_eq!(geom(&new_face.appearance.items), Some(unplaced.resolve(face_bounds)), "the lower-row fill keeps its fit"); + let AppearanceItem::Stroke(st) = &old_edge.appearance.items[0] else { panic!("a stroke") }; + let edge_box = st.paint_bounds(old_edge.geometric_bounds().unwrap()); + let new_edge = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap(); + assert_eq!(geom(&new_edge.appearance.items), Some(unplaced.resolve(edge_box)), "the stroke keeps its fit"); +} + fn live_group_only(s: &mut Session) -> NodeId { let r = s.execute("livePaint.make", &json!({})).unwrap(); NodeId(r["id"].as_u64().unwrap()) diff --git a/docs/mcp.md b/docs/mcp.md index 84c75a81d0..beecd227f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3486,6 +3486,12 @@ already walks Image Trace. Short agent entry points: `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most covering source, including compound paths with shared curved boundaries. Expand keeps those face fills. + `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity + is above 0, and a fill or stroke row is shown, has paint and an opacity above 0 (a stroke also a weight above 0), + whichever row is on top. A painted lower row under an unpainted top row keeps the part; a part whose only painted + row is hidden is removed. `livePaint.merge` copies the fill and stroke rows of each face that draws by this rule, and + of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the + strokes' pen pressure, tilt, bearing and rotation. With `detect: true`, paint stops at the gaps that run from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..3ab2127e30 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1447,6 +1447,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. + Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients}`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). @@ -1455,7 +1456,6 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`. As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`. A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. - A drag with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. From 94db0b1ebd4737d1744060e0dd127b6af9c46174 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:20:35 -0800 Subject: [PATCH 10/35] .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 9ab8b6ce16af81954f7e79e91bf6c8066f146af5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:12:45 -0800 Subject: [PATCH 11/35] i18n: German translates the messages no catalog had a row for The untranslated report listed 167 status and error messages with no row in any complete-message catalog, and 11 more that other catalogs translate but German did not, so with the interface in German these messages showed in English. - de.tsv gains 116 `@msg` rows for the listed messages: image export and encoding errors, color profiles, color books and swatch exchange files, binary DXF and DWG import, 3D and Revolve limits, and script, zip and file errors. They sit in one commented block after the existing message rows. - de.tsv gains 10 `@msg` rows for the Banner Hems and Grommets errors and the failed recovery save. - The 51 listed messages that open #7211 and #7378 translate (composite fonts and SVG filters), and the justification order error that open #7116 translates, are left out, so no row is added twice. - Placeholders, parameter names, command ids and literal values stay as the English writes them. The translations use the catalog's existing terms and typography. - docs/localization-parity.md and docs/target-app-parity.md give the German counts: 3,563 `@msg` and 48 `@msgplural` rows, 52 messages still listed. --- crates/ui-egui/src/i18n/de.tsv | 128 +++++++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 2 +- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 951f6f9c37..172657bff7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -8887,6 +8887,134 @@ breakText Glyphs Glyphen @msg the paths are too many or too tangled to find where they cross die Pfade sind zu zahlreich oder zu verschlungen, um ihre Kreuzungen zu finden @msg the rectangle or line reaches too many paths to check das Rechteck oder die Linie erreicht zu viele Pfade, um sie zu prüfen @msg {name} must be a number {name} muss eine Zahl sein +# Messages no catalog translated yet: image export and color profiles, color books and swatch exchange files, 3D and Revolve, and script and file errors. +@msg 3D effects require finite coordinates within 100 million points 3D-Effekte erfordern endliche Koordinaten innerhalb von 100 Millionen Punkt +@msg 3D effects require one to ten lights 3D-Effekte erfordern ein bis zehn Lichter +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-Farbtiefe {_1}: 1, 4, 8, 16, 24 oder 32 Bit pro Pixel +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP-Kodierung fehlgeschlagen: {width} × {height} Pixel passen nicht zum Format oder zum Pixelpuffer +@msg GIF encoding failed: the palette image is malformed GIF-Kodierung fehlgeschlagen: Das Palettenbild ist fehlerhaft +@msg GIF encoding failed: {e} GIF-Kodierung fehlgeschlagen: {e} +@msg JPEG colour profile: {e} JPEG-Farbprofil: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg JPEG encoding failed: {e} JPEG-Kodierung fehlgeschlagen: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg PNG encoding failed: the palette image is malformed PNG-Kodierung fehlgeschlagen: Das Palettenbild ist fehlerhaft +@msg PNG encoding failed: {e} PNG-Kodierung fehlgeschlagen: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG-Kodierung fehlgeschlagen: {width} × {height} Pixel sind keine gültige Bildgröße +@msg PSD colour profile: {e} PSD-Farbprofil: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg Revolve profile contains invalid coordinates Kreiseln: Das Profil enthält ungültige Koordinaten +@msg Revolve profile is too complex Kreiseln: Das Profil ist zu komplex +@msg Revolve projection is not finite Kreiseln: Die Projektion ist nicht endlich +@msg Revolve requires finite profile coordinates within 100 million points Kreiseln erfordert endliche Profilkoordinaten innerhalb von 100 Millionen Punkt +@msg Revolve surface is too complex; reduce Segments or simplify the profile Kreiseln: Die Oberfläche ist zu komplex; verringern Sie „Segmente“ oder vereinfachen Sie das Profil +@msg Revolve surface is too complex; simplify the profile Kreiseln: Die Oberfläche ist zu komplex; vereinfachen Sie das Profil +@msg Revolve visibility cannot split the projected boundary Kreiseln: Die Sichtbarkeitsberechnung kann die projizierte Begrenzung nicht teilen +@msg Revolve visibility creates too many fragments Kreiseln: Die Sichtbarkeitsberechnung erzeugt zu viele Fragmente +@msg Revolve visibility creates too many vector anchors Kreiseln: Die Sichtbarkeitsberechnung erzeugt zu viele Vektor-Ankerpunkte +@msg Revolve visibility exceeds its geometry work limit Kreiseln: Die Sichtbarkeitsberechnung überschreitet ihre Grenze für den Geometrieaufwand +@msg Revolve visibility exceeds its spatial index limit Kreiseln: Die Sichtbarkeitsberechnung überschreitet ihre Grenze für den räumlichen Index +@msg Revolve visibility has too many boundary crossings Kreiseln: Die Sichtbarkeitsberechnung hat zu viele Kreuzungen der Begrenzung +@msg Revolve visibility has too many input anchors Kreiseln: Die Sichtbarkeitsberechnung hat zu viele Eingabe-Ankerpunkte +@msg Revolve visibility requires convex cap sectors Kreiseln: Die Sichtbarkeitsberechnung erfordert konvexe Sektoren der Deckflächen +@msg Revolve visibility requires finite projected faces Kreiseln: Die Sichtbarkeitsberechnung erfordert endliche projizierte Flächen +@msg SVG options: {e} SVG-Optionen: {e} +@msg Save for Web settings: {e} Einstellungen für „Für Web speichern“: {e} +@msg TIFF colour profile: {e} TIFF-Farbprofil: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg TIFF encoding failed: {e} TIFF-Kodierung fehlgeschlagen: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-Farbtiefe {_1}: 16, 24 oder 32 Bit pro Pixel +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa-Kodierung fehlgeschlagen: Der Pixelpuffer passt nicht zur Bildgröße +@msg The 3D projection is not finite Die 3D-Projektion ist nicht endlich +@msg The 3D shadow is too complex; turn Shadows off or simplify the art Der 3D-Schatten ist zu komplex; schalten Sie „Schatten“ aus oder vereinfachen Sie die Grafik +@msg The shape contains invalid coordinates Die Form enthält ungültige Koordinaten +@msg Too many Revolve faces for visibility evaluation Zu viele Kreiseln-Flächen für die Sichtbarkeitsberechnung +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft liest höchstens {ASE_MAX_ENTRIES} Farben und Gruppen aus einer Farbfeld-Austauschdatei, und diese Bibliothek hat {entries} +@msg WebP encoding failed: {e} WebP-Kodierung fehlgeschlagen: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` ist smallThumb, mediumThumb, largeThumb, smallList oder largeList +@msg a line can't run from a point to itself eine Linie kann nicht von einem Punkt zu sich selbst verlaufen +@msg a line needs at least two points eine Linie braucht mindestens zwei Punkte +@msg a linked JavaScript file is outside the read roots eine verknüpfte JavaScript-Datei liegt außerhalb der Lesestammordner +@msg a number cannot be read eine Zahl kann nicht gelesen werden +@msg a swatch exchange block is too long ein Block der Farbfeld-Austauschdatei ist zu lang +@msg a swatch exchange name is too long ein Name in der Farbfeld-Austauschdatei ist zu lang +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels ein Bild mit {sw} × {sh} Pixeln kann nicht auf {width} × {height} Pixel skaliert werden +@msg a {width} × {height} image is too large ein Bild mit {width} × {height} Pixeln ist zu groß +@msg a {width} × {height} image: {e} ein Bild mit {width} × {height} Pixeln: {e} +@msg an image could not be written: {e} ein Bild konnte nicht geschrieben werden: {e} +@msg an image that couldn't be read was left out ein Bild, das nicht gelesen werden konnte, wurde ausgelassen +@msg can't open “{name}”: {e} „{name}“ kann nicht geöffnet werden: {e} +@msg cannot align zip entries to {align} bytes die ZIP-Einträge können nicht auf {align} Byte ausgerichtet werden +@msg cannot open the automation {kind} root `{_1}`: {e} der Automatisierungs-Stammordner ({kind}) `{_1}` kann nicht geöffnet werden: {e} +@msg cannot read profile: {0} das Profil kann nicht gelesen werden: {0} +@msg color book version {version} isn't supported (only 1) die Farbbuchversion {version} wird nicht unterstützt (nur 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) Farbbücher im Farbraum {other} werden nicht unterstützt (nur RGB, CMYK und Lab) +@msg file name too long to zip: {_1} der Dateiname ist zu lang für ein ZIP-Archiv: {_1} +@msg file name too long to zip: {name} der Dateiname ist zu lang für ein ZIP-Archiv: {name} +@msg freeform line {i}: {e} Freiformlinie {i}: {e} +@msg freeform point {i}: {e} Freiformpunkt {i}: {e} +@msg give `{key}` or `{other}`, not both geben Sie `{key}` oder `{other}` an, nicht beides +@msg grayscale profiles can't be working spaces yet Graustufenprofile können noch keine Arbeitsfarbräume sein +@msg input contains NaN or infinite coordinates die Eingabe enthält NaN oder unendliche Koordinaten +@msg input path could not be closed der Eingabepfad konnte nicht geschlossen werden +@msg invalid ICC profile: {0} ungültiges ICC-Profil: {0} +@msg invalid scale {scale} ungültige Skalierung {scale} +@msg its localized names take more than {_1} KB seine lokalisierten Namen belegen mehr als {_1} KB +@msg its objects are nested too deep ({e}) seine Objekte sind zu tief verschachtelt ({e}) +@msg its tag table lists the `{_1}` tag more than once seine Tag-Tabelle führt das Tag `{_1}` mehr als einmal auf +@msg not a graphic style library: {e} keine Grafikstil-Bibliothek: {e} +@msg not a swatch library (.vcswatches or .gpl) keine Farbfeldbibliothek (.vcswatches oder .gpl) +@msg not a swatch library (format `{_1}`) keine Farbfeldbibliothek (Format `{_1}`) +@msg not a swatch library: {e} keine Farbfeldbibliothek: {e} +@msg not an action set file ({e}) keine Aktionssatzdatei ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray der Farbraum {other} des Profils ist nicht RGB, CMYK oder Graustufen +@msg regular expression too large regulärer Ausdruck zu groß +@msg script file is not UTF-8 text: {value} die Skriptdatei ist kein UTF-8-Text: {value} +@msg stop {i}: {e} Farbstopp {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) die Version {major}.{minor} der Farbfeld-Austauschdatei wird nicht unterstützt (nur 1.x) +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it die Grafik eines Diagrammdesigns in einer Diagrammreihe wird aus dem Design neu gezeichnet: Bearbeiten Sie das Design, um sie zu ändern +@msg the color book holds more than {ASE_MAX_ENTRIES} colors das Farbbuch enthält mehr als {ASE_MAX_ENTRIES} Farben +@msg the color book is cut short das Farbbuch ist unvollständig +@msg the color book's names take more than {_1} MB die Namen des Farbbuchs belegen mehr als {_1} MB +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution das Bild hätte {w} × {h} Pixel; Rasterexporte sind auf {MAX_RASTER_SIDE} Pixel pro Seite und {_1} Megapixel begrenzt: Verringern Sie die Skalierung oder die Auflösung +@msg the optimised image can't be read back: {e} das optimierte Bild kann nicht zurückgelesen werden: {e} +@msg the pixel buffer doesn't match the image size der Pixelpuffer passt nicht zur Bildgröße +@msg the preview: {e} die Vorschau: {e} +@msg the request may not read the New Document profiles folder die Anfrage darf den Ordner der Profile für neue Dokumente nicht lesen +@msg the shapes are too degenerate to combine die Formen sind zu entartet, um sie zu kombinieren +@msg the shapes cross themselves and each other too often to combine die Formen überschneiden sich selbst und einander zu oft, um sie zu kombinieren +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups die Farbfeld-Austauschdatei enthält mehr als {ASE_MAX_ENTRIES} Farben und Gruppen +@msg the swatch exchange file is cut short die Farbfeld-Austauschdatei ist unvollständig +@msg the {_1} plate can't be read: {e} der Farbauszug {_1} kann nicht gelesen werden: {e} +@msg this binary DXF file is damaged or cut short diese binäre DXF-Datei ist beschädigt oder unvollständig +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als DXF und öffnen Sie diese Datei +@msg too many classes zu viele Zeichenklassen +@msg too many groups zu viele Gruppen +@msg too many layers for PSD (at most 32767) zu viele Ebenen für PSD (höchstens 32767) +@msg too many swatch exchange blocks zu viele Blöcke in der Farbfeld-Austauschdatei +@msg unknown colour profile: {0} unbekanntes Farbprofil: {0} +@msg {_1} clashes with a built-in profile name {_1} kollidiert mit dem Namen eines integrierten Profils +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} Pixel ist zu groß für GIF (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung +@msg {_1}: no usable transform to/from sRGB {_1}: keine verwendbare Transformation von/nach sRGB +@msg {name} is not a CMYK profile {name} ist kein CMYK-Profil +@msg {name} is not an RGB profile {name} ist kein RGB-Profil +@msg {path}: could not be read {path}: konnte nicht gelesen werden +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} Pixel ist zu groß für JPEG (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} Pixel ist zu groß für Targa (höchstens 65535 Pixel pro Seite): Verringern Sie die Auflösung +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} Pixel ist zu groß für PSD (höchstens {MAX_SIDE} Pixel pro Seite): Verringern Sie die Auflösung +# Messages other catalogs translate and German did not: Banner Hems and Grommets and recovery data. +@msg `{key}` must be from 0.01 to 1000 pt `{key}` muss zwischen 0,01 und 1000 pt liegen +@msg select the banner, or mark every artboard wählen Sie das Banner aus oder markieren Sie alle Zeichenflächen +@msg the banner has no width or height das Banner hat keine Breite oder Höhe +@msg the grommet inset is too large for the banner der Randabstand der Ösen ist zu groß für das Banner +@msg the hem is too deep for the banner der Saum ist zu tief für das Banner +@msg the spacing makes more than {MAX_GROMMETS} grommets der Abstand ergibt mehr als {MAX_GROMMETS} Ösen +@msg there is no banner to mark es gibt kein Banner zum Markieren +@msg turn on at least one side aktivieren Sie mindestens eine Seite +@msg with no hem and no grommets there is nothing to mark ohne Saum und ohne Ösen gibt es nichts zu markieren +@msg {saving} failed: {e} {saving} fehlgeschlagen: {e} Banner Hems and Grommets… Bannersäume und Ösen… Banner Hems and Grommets Bannersäume und Ösen Hem Saum diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6cbad8f5e3..da94e3c977 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -35,7 +35,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | Portuguese | `pt-br` | 9,260 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 39 missing messages for it; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | | Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | -| German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,433 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 48 missing messages for it) | Latin | contributor-written, not reviewed | full | — (review) | +| German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (3,563 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 52 missing messages for it, which open #7116, #7211 and #7378 translate) | Latin | contributor-written, not reviewed | full | — (review) | | Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | | Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (stacked marks attach in precomposed, decomposed and keyboard forms, #4491; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..d618eb0867 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -902,7 +902,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Simplified Chinese catalog (`crates/ui-egui/src/i18n/zh-hans.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Simplified Chinese messages: the composite font, justification order and Blob Brush connect end errors that open #7211, #7116 and #7330 translate, and two messages that no catalog has a row for yet. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - Edit › Undo and Edit › Redo, in the in-window and native menus, name the step in the interface language as the History panel lists it (`panels::history::step_name`), so when the catalog has no plain row for a step named after an effect, such as Feather, the menu shows the translation of the effect's menu row without its ellipsis (`undo_and_redo_name_the_step_as_the_history_panel_does`). - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - - The German catalog (`crates/ui-egui/src/i18n/de.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,433 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 48 German messages; 38 of them are the composite font, select-a-path and justification order errors that open #7211, #7214 and #7116 translate. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. + - The German catalog (`crates/ui-egui/src/i18n/de.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,563 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins, including the image export, color profile, color book, swatch exchange file, DXF import and Revolve errors that no other catalog translates yet. The report lists 52 German messages: the composite font and SVG filter errors that open #7211 and #7378 translate, and the justification order error that open #7116 translates. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. From 7c40cd2df2984c91da6e052daa3a162ff6d07dae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:56:38 -0800 Subject: [PATCH 12/35] Plug-ins: unlinking a gradient stop's spot swatch also clears its plates A filter or importer that changes a gradient paint unlinks each stop from its swatch and resets its tint, but left the stop's process plates. The PDF and EPS writers read plates only with a spot swatch, so the stop kept a plates array with no ink, saved with the document and compared in later edits. Unlinking now clears the plates too, in fills and strokes; a paint returned unchanged keeps its link, tint and plates. The plate clamping test now checks the decoded output, before admission. --- crates/plugins/src/objects.rs | 6 +-- crates/plugins/src/objects/tests.rs | 68 ++++++++++++++++++++++++++--- docs/plugins.md | 4 +- docs/target-app-parity.md | 1 + 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 3e844415a7..192ed24a96 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -951,7 +951,8 @@ fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> { } /// `p` without swatch links: a plug-in's colours are its own, so only paints it returned -/// unchanged keep theirs. +/// unchanged keep theirs. A gradient stop also loses its process plates, which print only with +/// a spot swatch. fn unlinked(mut p: Paint) -> Paint { match &mut p { Paint::Solid { swatch, tint, .. } => { @@ -961,8 +962,7 @@ fn unlinked(mut p: Paint) -> Paint { Paint::Gradient(g) => { g.swatch = None; for s in &mut g.gradient.stops { - s.swatch = None; - s.tint = 1.0; + s.set_color(s.color, None); } } Paint::None | Paint::Pattern { .. } => {} diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 3483971355..5fb48cf396 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -109,27 +109,81 @@ fn only_a_radial_keeps_a_focal_point_through_a_plug_in() { } } -/// A gradient stop's process plates handed back out of range are clamped to 0..1, each in its +/// A gradient stop's process plates handed back out of range are read clamped to 0..1, each in its /// place, in a fill and in a stroke (a JSON number too large for `f32` is infinite); plates in range /// and a stop without plates are kept. #[test] fn gradient_stop_plates_are_clamped_through_a_plug_in() { use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; - let (mut d, ids, _) = doc(); + let (_, ids, _) = doc(); let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(0.5, Color::gray(0.5)), GradientStop::new(1.0, Color::BLACK)]; let mut p = serde_json::to_value(Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))))).unwrap(); - p["gradient"]["stops"][0]["plates"] = json!([-0.5, 1.5, -1e300, 1e300]); - p["gradient"]["stops"][1]["plates"] = json!([0.25, 2.0, 0.75, -3.0]); + for (i, plates) in [(0, json!([-0.5, 1.5, -1e300, 1e300])), (1, json!([0.25, 2.0, 0.75, -3.0]))] { + p["gradient"]["stops"][i]["plates"] = plates; + p["gradient"]["stops"][i]["swatch"] = json!("Spot"); + } let out = json!({"objects": [{"id": ids[0].0, "fills": [p.clone()], "strokes": [{"paint": p}]}]}); - apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap(); - let ap = &d.node(ids[0]).unwrap().appearance; - for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] { + let out = decode_json(out).unwrap(); + let fill = &out[0].fills.as_ref().unwrap()[0]; + let stroke = &out[0].strokes.as_ref().unwrap()[0].paint; + for (what, paint) in [("fill", fill), ("stroke", stroke)] { let Paint::Gradient(g) = paint else { panic!("a gradient {what}") }; let plates: Vec<_> = g.gradient.stops.iter().map(|s| s.plates).collect(); assert_eq!(plates, [Some([0.0, 1.0, 0.0, 1.0]), Some([0.25, 1.0, 0.75, 0.0]), None], "{what}"); } } +/// A gradient linked to a gradient swatch, whose first stop is linked to a spot swatch at a tint +/// with process plates. +fn spot_gradient(color: Color) -> Paint { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let mut spot = GradientStop::new(0.0, color); + spot.midpoint = 0.3; + spot.opacity = 0.75; + spot.set_color(color, Some(("Spot".into(), 0.4))); + spot.plates = Some([0.1, 0.2, 0.3, 0.4]); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![spot, GradientStop::new(1.0, Color::BLACK)])); + g.swatch = Some("Blend".into()); + Paint::Gradient(Box::new(g)) +} + +/// A changed gradient handed back loses each stop's spot link, tint and process plates, in a fill +/// and in a stroke, and keeps the stop's color, offset, opacity and midpoint; a new stop handed back +/// with plates and no link has none. +#[test] +fn changed_gradient_fill_and_stroke_drop_spot_plates() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + let was = Color::cmyk(0.0, 0.5, 1.0, 0.0); + d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(was)); + d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(was)); + let now = Color::cmyk(0.0, 0.25, 0.5, 0.0); + let mut p = serde_json::to_value(spot_gradient(now)).unwrap(); + p["gradient"]["stops"][1]["plates"] = json!([0.5, 0.5, 0.5, 0.5]); + let out = json!({"objects": [{"id": a.0, "fills": [p.clone()], "strokes": [{"paint": p}]}]}); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap(); + let ap = &d.node(a).unwrap().appearance; + for (what, paint) in [("fill", ap.fill_paint()), ("stroke", ap.stroke_paint())] { + let Paint::Gradient(g) = paint else { panic!("a gradient {what}") }; + assert_eq!(g.swatch, None, "{what}"); + let stops: Vec<_> = g.gradient.stops.iter().map(|s| (s.offset, s.color, s.opacity, s.midpoint, s.swatch.clone(), s.tint, s.plates)).collect(); + assert_eq!(stops, [(0.0, now, 0.75, 0.3, None, 1.0, None), (1.0, Color::BLACK, 1.0, 0.5, None, 1.0, None)], "{what}"); + } +} + +/// A gradient handed back as it was keeps its stops' spot links, tints and process plates. +#[test] +fn unchanged_gradient_paint_keeps_spot_plates() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + d.node_mut(a).unwrap().appearance.set_fill(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0))); + d.node_mut(a).unwrap().appearance.set_stroke(spot_gradient(Color::cmyk(0.0, 0.5, 1.0, 0.0))); + let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap(); + let before = d.clone(); + apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap(); + assert_eq!(d, before); +} + /// A plate that is not a number is 0; infinities go to the nearest end of 0..1. #[test] fn plates_that_are_not_finite_are_in_range() { diff --git a/docs/plugins.md b/docs/plugins.md index 5f1479748e..fac6460249 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -617,7 +617,7 @@ An **importer** gets the bytes of the file being opened, as they are (not JSON), `paint`, `width` in points, `join` (`"miter"`, `"round"` or `"bevel"`), `cap` (`"butt"`, `"round"` or `"square"`) and `miterLimit`. Paints use the native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, - midpoint}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", + midpoint, swatch?, tint?, plates?}], "dither"?, "method"?: "Perceptual"|"HueClockwise"|"HueCounterclockwise"}, "geom"?, "angle", "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except Lab's. - `effects` (filters): the object's own effects in order, as its effect records: `id` (as `effect.list` lists it, or @@ -742,7 +742,7 @@ most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops clamped into range, stroke weights to 0–1,000 pt and miter limits to 1–500; a pattern must exist in the document. A gradient stop's four process `plates` are clamped to 0–1 as well: a value that is not a number becomes 0, and an infinite one becomes 0 or 1 by its sign. A paint returned unchanged keeps its swatch link; any other paint is -unlinked. One refused effect record refuses the whole output. +unlinked, and each of its gradient stops then has no swatch, a tint of 1 and no `plates`. One refused effect record refuses the whole output. A filter's output may also hold `documentData`: any JSON of at most 1 MiB, which replaces what the plug-in keeps with the document (`null` removes it), in the same undo step as its objects. Settings that belong to the whole document diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..39c1eb14cf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1929,6 +1929,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live-effect plug-ins before a fill's or stroke's own 3D effect:** a live effect listed before a fill's or stroke's own Extrude & Bevel, Inflate or Rotate effect is given `_context.item` for that fill or stroke (`{"fill": i}` or `{"stroke": i}`), not `"object"`, in the drawn art, in the 3D meshes and in Expand as Wireframes; a deferred stage there is listed at that fill's or stroke's place. Regressions: `crates/effects/src/tests/solidcontext.rs`. Scores and hours unchanged. - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Gradient stop plates from filter plug-ins are clamped (G17):** the four process `plates` of a gradient stop in a filter's or importer's output are clamped to 0–1, as its other colors are; a value that is not a number becomes 0, and an infinite one becomes 0 or 1 by its sign. Painted live effects still refuse such a stop. Regressions: `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. +- **Changed plug-in gradients lose spot plates (G17):** a gradient that a filter or importer changes loses each stop's swatch link, tint and process `plates` together, in fills and strokes, and keeps the stop's color, offset, opacity and midpoint; a gradient returned unchanged keeps all three. Regressions: `crates/plugins/src/objects/tests.rs` (`changed_gradient_fill_and_stroke_drop_spot_plates`, `unchanged_gradient_paint_keeps_spot_plates`). Scores and hours unchanged. - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. From 85e0d59bfa88b145f38658037e96d2a4884e4f9a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 18:53:52 -0800 Subject: [PATCH 13/35] Brushes: Scale Proportionately art grows with the stroke weight An Art brush set to Scale Proportionately drew its art at the same size on every stroke weight: the art was scaled across only by how much the path stretched it along and by the brush's Width. Stretch to Fit Stroke Length and Stretch Between Guides already multiply by the stroke weight. Proportional art now does too, so a 3 pt stroke draws it three times as wide as a 1 pt stroke, still as long as the path. The same applies to each run with Overlap on and to a Width that follows the pen. Existing Scale Proportionately art brush strokes at weights other than 1 pt draw scaled by the stroke weight. Strokes at 1 pt draw as before. --- crates/brush/src/lib.rs | 3 ++- crates/brush/src/tests.rs | 44 +++++++++++++++++++++++++++++++++++++++ crates/brush/src/warp.rs | 21 +++++-------------- docs/target-app-parity.md | 1 + 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 080462d0b6..851afc9e7a 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -351,7 +351,8 @@ pub enum Direction { #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "mode")] pub enum ArtScale { - /// Keep the art's proportions (length follows the path; width scales with it). + /// Keep the art's proportions (length follows the path; width scales with it, times the + /// stroke weight and the brush width). Proportional, /// Stretch the art to the path length. #[default] diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 2d62a1a993..ecaba1266a 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -141,6 +141,50 @@ fn art_brush_proportional_keeps_aspect() { assert!(near(bounds(&out).height(), 8.0, 1e-6)); } +/// Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch art +/// is, on top of the scale the path length gives it. +#[test] +fn art_brush_proportional_grows_with_weight() { + let brush = |width: f64, overlap: bool| { + let mut b = art_brush(rect_art(100.0, 4.0), ArtScale::Proportional); + if let BrushKind::Art(a) = &mut b.kind { + (a.width, a.overlap) = (width, overlap); + } + b + }; + let draw = |b: &Brush, bp: &BezPath, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, bp, st); + // 3 pt stroke → 3× wider than at 1 pt (8 pt); the length still follows the path. + let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0))); + assert!(near(b.height(), 24.0, 1e-6) && near(b.x0, 0.0, 1e-6) && near(b.x1, 200.0, 1e-6), "{b:?}"); + // The Width (50 %) applies on top of the stroke weight. + let b = bounds(&draw(&brush(50.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0))); + assert!(near(b.height(), 12.0, 1e-6), "{b:?}"); + // A path twice as long at twice the weight (as Scale Strokes & Effects at 200 % leaves it): + // 2× from the length and 2× from the weight. + let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 400.0, 0.0), &stroke(2.0))); + assert!(near(b.height(), 32.0, 1e-6), "{b:?}"); + // With Overlap, each run between corners is scaled by its own length and by the weight: the + // 100 pt run 4 pt × 3 across, the 50 pt run 2 pt × 3. + let mut bent = BezPath::new(); + bent.move_to((0.0, 0.0)); + bent.line_to((100.0, 0.0)); + bent.line_to((100.0, 50.0)); + let out = draw(&brush(100.0, true), &bent, &stroke(3.0)); + assert_eq!(out.len(), 2); + let (r0, r1) = (out[0].geometric_bounds().unwrap(), out[1].geometric_bounds().unwrap()); + assert!(near(r0.height(), 12.0, 1e-6) && near(r1.width(), 6.0, 1e-6), "{r0:?} {r1:?}"); + // A width that follows the pen: 50 % at the lightest pressure, 300 % at the heaviest, times + // the 2 pt weight (4 pt art on a 100 pt path: 4 pt across at 100 %, 1 pt either side). + let mut pen = brush(50.0, false); + if let BrushKind::Art(a) = &mut pen.kind { + (a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0)); + } + let ramp = StrokeLayer { width: 2.0, ..pressed(vec![(0.0, 0.0), (1.0, 1.0)]) }; + let out = draw(&pen, &line(0.0, 0.0, 100.0, 0.0), &ramp); + assert!(near(half_width(&out, 0.0, 1.0), 2.0, 0.1), "{}", half_width(&out, 0.0, 1.0)); + assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0)); +} + #[test] fn art_brush_between_guides_keeps_ends() { // Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path. diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index c72e1c33f8..edf0ac0567 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -465,17 +465,8 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> { bounds.windows(2).map(|w| (w[0], w[1])).collect() } -/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and -/// `width` the width (%) there. -fn art_on_run( - t: &Track, - (s0, s1): (f64, f64), - a: &ArtBrush, - (art, b): (&Node, Rect), - cross: f64, - width: f64, - widen: &dyn Fn(f64) -> f64, -) -> Option { +/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1. +fn art_on_run(t: &Track, (s0, s1): (f64, f64), a: &ArtBrush, (art, b): (&Node, Rect), cross: f64, widen: &dyn Fn(f64) -> f64) -> Option { let l = s1 - s0; if l <= 1e-9 { return None; @@ -483,10 +474,8 @@ fn art_on_run( let w = b.width(); let (along, cross) = match a.scale { ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross), - ArtScale::Proportional => { - let k = l / w; - (AlongMap::linear(w, s0, s1), k * width / 100.0) - } + // Scaled across as much as along, times the stroke weight and the width, as the other modes are. + ArtScale::Proportional => (AlongMap::linear(w, s0, s1), l / w * cross), ArtScale::BetweenGuides { start, end } => { let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w); let (g0, g1) = (g0.min(g1), g0.max(g1)); @@ -528,7 +517,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer) }; let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] }; for run in runs { - if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) { + if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, &widen) { out.push(n); } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..5f8e6e7ab0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1519,6 +1519,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step. - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random, Pressure, Tilt, Bearing or Rotation with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across, Overlap) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Pattern and Bristle brushes and on native Wayland and the web. Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules. + Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch to Fit Stroke Length and Stretch Between Guides art is, on top of the scale the path length gives it, so a 3 pt stroke draws the art three times as wide as a 1 pt stroke. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says Scale Proportionately keeps the art's proportions and does not say how the stroke weight applies; that rule is VectorCraft's own. Evidence: `tests::art_brush_proportional_grows_with_weight` (vectorcraft-brush). Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). - **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. From 00de304ab05a9f430592b677c43c1121a3f5eaa9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:12:05 -0800 Subject: [PATCH 14/35] Live Shapes: saved object.setLiveShape steps with a negative radius or out-of-range sides replay again Since #7294, object.setLiveShape refuses a negative `radius` and a `sides` that is not a whole number from 3 to 1000. Session::replay applied the same checks, so a journal entry or action step recorded before that change with such a value stopped with a parameter error. On replay, a negative radius now counts as 0, which is how it drew before. A whole side count below 3 or above 1000 is brought into that range, and a fractional or negative one is ignored. Direct calls still refuse these values. --- crates/engine/src/cmd/object/shape.rs | 17 +++++++++++++++-- crates/engine/src/tests/livecorners.rs | 24 ++++++++++++++++++++++++ docs/mcp.md | 4 +++- docs/target-app-parity.md | 2 +- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index e3909c5935..b30959de99 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -149,9 +149,22 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { }; let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0))); let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0"); - let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?; + // Saved steps ([`Session::replay`]) can hold values a direct call refuses, recorded before these + // checks: a negative radius is 0, a whole side count of 0 or more is brought into 3 to 1000, and + // a fractional or negative one is ignored. A whole number written as 5.0 counts as 5, as in a + // direct call. + let replaying = s.replaying(); + let radius = if replaying { + number("radius", |_| true, "a number")?.map(|r| r.max(0.0)) + } else { + number("radius", |r| r >= 0.0, "a radius of 0 or more")? + }; // A polygon's side count: a whole number from 3 to 1000. - let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64); + let sides = if replaying { + number("sides", |_| true, "a number")?.filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(1000.0) as u64) + } else { + number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64) + }; // An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them). let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?); let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true); diff --git a/crates/engine/src/tests/livecorners.rs b/crates/engine/src/tests/livecorners.rs index 190cd0d354..b9e3793913 100644 --- a/crates/engine/src/tests/livecorners.rs +++ b/crates/engine/src/tests/livecorners.rs @@ -126,6 +126,30 @@ fn bad_sides_radii_and_keys_are_errors() { assert_eq!(corners(&s, id).0, [0.0; 4]); } +/// Saved steps play as they did before `sides` and `radius` were checked: a negative radius +/// removes the rounding, a whole side count outside 3 to 1000 is brought into that range, and a +/// fractional or negative side count is ignored. +#[test] +fn saved_steps_with_out_of_range_sides_or_radius_still_play() { + let (mut s, id) = session(); + let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap()); + let replay = |s: &mut Session, p: Value| s.replay("object.setLiveShape", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + run(&mut s, json!({"id": id.0, "radius": 6})); + replay(&mut s, json!({"id": id.0, "radius": -4})); + assert_eq!(corners(&s, id).0, [0.0; 4]); + let sides = |s: &Session| match node_path(s, poly).1 { + Some(LiveShape::Polygon { sides, .. }) => sides, + other => panic!("{other:?}"), + }; + for (n, want) in [(json!(2), 3), (json!(1001), 1000), (json!(2.5), 1000), (json!(-1), 1000), (json!(5.0), 5)] { + replay(&mut s, json!({"ids": [poly.0], "sides": n})); + assert_eq!(sides(&s), want, "{n}"); + } + // A direct call still refuses them. + assert!(s.execute("object.setLiveShape", &json!({"id": id.0, "radius": -4})).is_err()); + assert!(s.execute("object.setLiveShape", &json!({"ids": [poly.0], "sides": 2})).is_err()); +} + #[test] fn direct_selected_corners_round_alone_and_stay_selected() { let (mut s, id) = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..87c4260377 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5546,7 +5546,9 @@ one undo step. `object.setLiveShape` returns a parameter error, and leaves the document and its undo history as they were, for a key it does not take, at the top level (`` unknown parameter `path` ``) or in one of `items` (`` unknown parameter `items[0].corner`; did you mean `items[0].corners`? ``), for a `sides` that is not a whole number -from 3 to 1000, such as 2.5, 2 or 1001, and for a negative `radius`. A `radius` of 0 removes the rounding. +from 3 to 1000, such as 2.5, 2 or 1001, and for a negative `radius`. A `radius` of 0 removes the rounding. Saved +steps (a journal or an action) replay these values without an error: a negative `radius` counts as 0, a whole `sides` +below 3 or above 1000 counts as 3 or 1000, and a fractional or negative `sides` is ignored. ```json {"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"sides":8,"polygonAngle":22.5,"sideLength":40}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..ea3a8977ad 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1587,7 +1587,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Create Object Mosaic (its columns and rows), Transform Each (its reference point and Random seed) and Rearrange All Artboards (its columns) refuse a fraction, a negative number or a string, such as 2.5, -1 or "4"; 4.0 counts as 4. Move, Rotate, Scale, Reflect, Shear, Transform Again, Nudge and the Transform panel's values (their copy, Scale Strokes & Effects, Scale Corners, Transform Patterns, Transform Objects and area type options), Align, Distribute and Distribute Spacing (Transform Patterns), Object Properties (visible, locked, Isolate Blending and Opacity & Mask Define Knockout Shape) and Live Shape Properties (Invert Pie, Make Sides Equal and relative rounding) refuse a value other than true, false or null, such as "false", and leave the document as it was. Object Properties (its opacity), Live Shape Properties (corner radius and polygon sides), the Transform panel's reference point and Align, Distribute and Distribute Spacing to an artboard (the artboard's number) refuse a value other than a number or null, such as "50", and leave the document as it was. - Live Shape Properties refuses a key it does not take, at the top level or in one of its items, a polygon side count that is not a whole number from 3 to 1000 and a negative corner radius, and leaves the document as it was; the Transform panel's Sides field takes 3 to 1000. + Live Shape Properties refuses a key it does not take, at the top level or in one of its items, a polygon side count that is not a whole number from 3 to 1000 and a negative corner radius, and leaves the document as it was; the Transform panel's Sides field takes 3 to 1000. Saved steps with such a side count or radius still replay, as they did before the check. Transform Each (its scales, moves and rotation), Rasterize (its resolution), Create Object Mosaic (its new width and height and its spacing), Create Trim Marks (its offset, length, bleed and weight) and Rearrange All Artboards (its spacing) refuse a value other than a number or null, such as "50", and change nothing. Crop Image refuses a rectangle that is not four numbers, such as "0, 0, 20, 20". Transform Each (Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale Corners, Transform Objects and Transform Patterns), Create Object Mosaic (gray tiles and deleting the image), Crop Image (its trim), Create Trim Marks (all artboards) and Rearrange All Artboards (by column, Move Artwork with Artboard and Transform Patterns) refuse a value other than true, false or null, such as "false", and change nothing. Create Trim Marks refuses a style that is not text, such as 5. New Artboard refuses a position or size other than a number or null, such as "10", and New Artboard and Artboard Options refuse a name other than text or null, such as 5; each leaves the artboards and the undo history as they were. From d06870e41413258c734a2a63a24df37698af484e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:12:19 -0800 Subject: [PATCH 15/35] Docs: DXF export of a non-circular ellipse is listed as a native ELLIPSE The DXF SPLINE line in docs/target-app-parity.md said a non-circular ellipse keeps the existing fallback. crates/cad/src/scene.rs writes a closed four-cubic ellipse, or a circle scaled out of proportion, as a native ELLIPSE in R13 and later, unless its minor-to-major ratio is below 1e-6, and only R12 keeps the fallback. The line now says so. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..d3dc0b86c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1725,7 +1725,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, placed images and fill hatches left out), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE; a closed four-cubic ellipse, or a circle scaled out of proportion, exports as a native ELLIPSE in R13 and later (an ellipse whose minor-to-major ratio is below 1e-6 is still written as a polyline or spline), and R12 keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, placed images and fill hatches left out), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes. An open path's fill outline is closed, as the fill is, in every version (`an_open_path_s_fill_outline_keeps_its_closing_edge`, `fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). From 9b2e21f6ca9b8603b14c7f97725d24b16a54980d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:14:37 -0800 Subject: [PATCH 16/35] Live Paint: Merge keeps the visibility, opacity and blending of the parts it carries Merge copied a face's or edge's appearance to the part it rebuilds in its place, but not its visibility, opacity or blending mode. A half-transparent face drawn by its stroke became opaque, and a hidden edge was shown again. --- crates/engine/src/cmd/buildcmds/live_paint.rs | 56 ++++++++++++------- crates/engine/src/tests/build.rs | 28 ++++++++++ docs/mcp.md | 6 +- docs/target-app-parity.md | 2 +- 4 files changed, 68 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 04c6a3fb1b..9c3c750996 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -84,17 +84,33 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } -/// The appearance of face or edge `n` for the part Merge makes again in its place, another path: -/// gradients fitted to `n`'s outline keep that fit, and the strokes' pen pressure, tilt, bearing -/// and rotation stay behind. -fn carried_appearance(n: &Node) -> Appearance { - let mut app = (*n.appearance).clone().without_pressure(); - if app.has_unplaced_gradient() - && let Some(bounds) = n.geometric_bounds() - { - app.pin_gradients(bounds); +/// What Merge carries from a face or edge to the part it makes again in its place, another path: +/// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep +/// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind. +struct Carried { + appearance: Appearance, + visible: bool, + opacity: f32, + blend: vectorcraft_color::BlendMode, +} + +impl Carried { + fn of(n: &Node) -> Self { + let mut appearance = (*n.appearance).clone().without_pressure(); + if appearance.has_unplaced_gradient() + && let Some(bounds) = n.geometric_bounds() + { + appearance.pin_gradients(bounds); + } + Self { appearance, visible: n.visible, opacity: n.opacity, blend: n.blend } + } + + fn onto(self, n: &mut Node) { + n.appearance = self.appearance.into(); + n.visible = self.visible; + n.opacity = self.opacity; + n.blend = self.blend; } - app } /// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its @@ -108,16 +124,16 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; check_live_paint_takes(d, roots)?; let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, appearance) for faces that draw, and (point on - // edge, appearance) for every edge. - let keep_faces: Vec<(Point, Appearance)> = - b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, carried_appearance(f)))).collect(); - let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) + // Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge, + // paint) for every edge. + let keep_faces: Vec<(Point, Carried)> = + b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect(); + let keep_edges: Vec<(Point, Carried)> = b::edges(&old) .iter() .filter_map(|e| { let pd = e.path_data()?; let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), carried_appearance(e))) + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e))) }) .collect(); let mut sources = source_nodes(&old); @@ -137,20 +153,20 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result *ch = children; } g.extras_mut().live_paint_gaps = stored_gap_options(options); - for (pt, app) in keep_faces { + for (pt, carried) in keep_faces { if let Some(fid) = face_at(&g, pt).map(|f| f.id) && let Some(ch) = g.children_mut() && let Some(f) = ch.iter_mut().find(|c| c.id == fid) { - Arc::make_mut(f).appearance = app.into(); + carried.onto(Arc::make_mut(f)); } } - for (pt, app) in keep_edges { + for (pt, carried) in keep_edges { if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) && let Some(ch) = g.children_mut() && let Some(e) = ch.iter_mut().find(|c| c.id == eid) { - Arc::make_mut(e).appearance = app.into(); + carried.onto(Arc::make_mut(e)); } } let mut merged = Beside::replacing(d, roots); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 0e6bf5ad8d..19ac53494c 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3105,6 +3105,34 @@ fn live_paint_merge_keeps_the_fit_of_carried_gradients() { assert_eq!(geom(&new_edge.appearance.items), Some(unplaced.resolve(edge_box)), "the stroke keeps its fit"); } +/// Merge carries a face's or edge's visibility, opacity and blending mode to the rebuilt part with +/// its appearance: a half-transparent face drawn by a stroke alone stays half transparent, and a +/// hidden edge stays hidden. +#[test] +fn live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts() { + use vectorcraft_color::BlendMode; + use vectorcraft_doc::StrokeLayer; + use vectorcraft_tools::builder::{edge_near, face_at}; + let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 2.0)); + let mut s = session(); + let g = live(&mut s); + let face = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![stroke()], 0.5); + let edge = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke()], 1.0); + s.edit("t", |d, _| { + d.node_mut(face).unwrap().blend = BlendMode::Multiply; + d.node_mut(edge).unwrap().visible = false; + Ok(()) + }) + .unwrap(); + let c = rect(&mut s, 120.0, 120.0, 100.0, 20.0); + s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + let n = node(&s, g); + let f = face_at(&n, Point::new(25.0, 50.0)).unwrap(); + assert_eq!((f.appearance.items.clone(), f.opacity, f.blend, f.visible), (vec![stroke()], 0.5, BlendMode::Multiply, true)); + let e = edge_near(&n, Point::new(0.0, 50.0), 0.5).unwrap(); + assert_eq!((e.appearance.items.clone(), e.visible), (vec![stroke()], false), "the hidden edge stays hidden"); +} + fn live_group_only(s: &mut Session) -> NodeId { let r = s.execute("livePaint.make", &json!({})).unwrap(); NodeId(r["id"].as_u64().unwrap()) diff --git a/docs/mcp.md b/docs/mcp.md index 6f7406f4a1..8f0849f912 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3489,9 +3489,9 @@ already walks Image Trace. Short agent entry points: `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity is above 0, and a fill or stroke row is shown, has paint and an opacity above 0 (a stroke also a weight above 0), whichever row is on top. A painted lower row under an unpainted top row keeps the part; a part whose only painted - row is hidden is removed. `livePaint.merge` copies the fill and stroke rows of each face that draws by this rule, and - of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the - strokes' pen pressure, tilt, bearing and rotation. + row is hidden is removed. `livePaint.merge` copies the fill and stroke rows, visibility, opacity and blending mode of + each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on + the old outline and without the strokes' pen pressure, tilt, bearing and rotation. With `detect: true`, paint stops at the gaps that run from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ab2127e30..1c9dda5a5d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1447,7 +1447,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. - Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients}`. + Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients, live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts}`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). From 155c2f722b6174dd735b4f4440678a8d4e223eaf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:18:35 -0800 Subject: [PATCH 17/35] Place: Placement Options clip a DCS image inside its file's clipping path When links.update or links.relink re-read a DCS file and Placement Options also clipped the image (Fill with Clip, or an aspect change that overflows), the placement rectangle replaced the file's clipping path, so plate art outside that path showed and printed. The file's clip group now stays around the image and the placement's clip goes in a clip group around it. The clip group holding just the file's group takes that clip, as one holding just an image does today, else a new one does, so later updates reuse the same two groups. A file that loses its clipping path leaves its group as a plain group inside the placement's clip. The same applies in symbols, graph designs, patterns and opacity masks. --- crates/engine/src/cmd/links.rs | 131 ++++++++++++++++++++++++++------- crates/engine/src/tests/dcs.rs | 112 ++++++++++++++++++++++++++++ docs/mcp.md | 2 +- 3 files changed, 217 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 3e1bc8b2ef..d37abc85c7 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -667,6 +667,16 @@ enum ClipTo { File(PathData), /// It goes into a new clip group of this path, inside the clip group that holds it. Nest(PathData), + /// Placement Options clip it to `placement` while its DCS file draws it through `file`: it is + /// clipped to `file` as [`ClipTo::File`] clips it, and the clip group holding it is clipped to + /// `placement` as [`ClipTo::Path`] clips an object. With no `file` (the new file lacks the + /// clipping path of the clip group holding it), that group stays as [`ClipTo::Off`] leaves it, + /// clipped to `placement`. + Both { file: Option, placement: PathData }, + /// Placement Options clip it to `placement` while its DCS file draws it through `file`, and the + /// clip group holding just it is not its old file's: that clip group's clipping path becomes + /// `placement`, and it goes into a new clip group of `file` inside it. + BothInside { file: PathData, placement: PathData }, /// The clipping path of the clip group holding just it goes (a DCS file's clipping path that /// its new file lacks): the group stays as a plain group of it, with its id, name, visibility /// and transparency. @@ -702,10 +712,10 @@ fn same_path(a: &PathData, b: &PathData) -> bool { } /// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with -/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file -/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path -/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip -/// groups get) goes. +/// `new_xf`: Placement Options' clip `clip` when they clip it, and the clipping path a DCS file +/// draws `img` through, the file's clip group inside the placement's. When neither clips it, a clip +/// group holding just it whose clipping path `held` is its old DCS file's (`old_clip`, in the old +/// pixels, under the nonzero rule its clip groups get) goes. fn clip_to( old_xf: Affine, old_clip: Option<&PathData>, @@ -714,16 +724,22 @@ fn clip_to( new_xf: Affine, held: Option<(&PathData, FillRule)>, ) -> ClipTo { - if let Some(clip) = clip { - return ClipTo::Path(clip_shape(clip)); - } // Whether the clip group holding just the image is the old DCS file's: a clipping path whose // fill rule was changed in the document is not. let file_held = match (old_clip, held) { (Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held), _ => false, }; - match img.clip.as_ref().map(|path| path.transformed(new_xf)) { + let file = img.clip.as_ref().map(|path| path.transformed(new_xf)); + if let Some(clip) = clip { + let placement = clip_shape(clip); + return match file { + Some(file) if held.is_some() && !file_held => ClipTo::BothInside { file, placement }, + file if file.is_some() || file_held => ClipTo::Both { file, placement }, + _ => ClipTo::Path(placement), + }; + } + match file { // A clipping mask made in the document stays, with the file's clip group inside it. Some(path) if held.is_some() && !file_held => ClipTo::Nest(path), Some(path) => ClipTo::File(path), @@ -752,7 +768,8 @@ impl Nested<'_> { let first = d.peek_next_id(); self.next = first; for l in d.layers.iter_mut() { - self.update(l, false); + // Nothing is picked in the layers themselves, so no layer is clipped. + let _ = self.update(l, false); } for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) { self.root(root); @@ -769,20 +786,30 @@ impl Nested<'_> { /// mask art). fn root(&mut self, n: &mut Arc) { if !(self.pick)(n) { - return self.update(n, true); + if let Some(placement) = self.update(n, true) { + *n = Arc::new(self.clip_group(Arc::clone(n), placement)); + } + return; } let (new, clip) = (self.make)(n, None); let mut new = Arc::new(new); - self.update(&mut new, true); + let _ = self.update(&mut new, true); *n = match clip { ClipTo::Path(path) | ClipTo::File(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)), + ClipTo::Both { file, placement } => self.clip_both(new, file, placement), + ClipTo::BothInside { file, placement } => self.clip_both(new, Some(file), placement), ClipTo::Keep | ClipTo::Off => new, }; } /// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays - /// the same node when none is there, and only the nodes on the way to one are copied. - fn update(&mut self, n: &mut Arc, nested: bool) { + /// the same node when none is there, and only the nodes on the way to one are copied. The + /// result is Placement Options' clip that `n` itself, now the clip group of a DCS file's + /// clipping path holding a picked object (or the plain group [`ClipTo::Off`] leaves), takes: + /// the clipping path of the clip group holding just `n` becomes it, else `n` goes into a new + /// clip group of it. + #[must_use] + fn update(&mut self, n: &mut Arc, nested: bool) -> Option { let mask = n.extras().mask.as_ref().and_then(|m| { let mut art = Arc::clone(&m.art); self.root(&mut art); @@ -794,6 +821,8 @@ impl Nested<'_> { // The clipping path and object of a plain group holding just the object, which becomes a // clip group again. let mut reclipped = None; + // Placement Options' clip for `n` itself. + let mut around = None; if let Some(children) = n.children() { // A clip group holding just the object: its clipping path changes. let clip_path = match (&n.kind, &children[..]) { @@ -804,9 +833,11 @@ impl Nested<'_> { for (i, c) in children.iter().enumerate() { if !(nested && (self.pick)(c)) { let mut new = Arc::clone(c); - self.update(&mut new, nested); - if !Arc::ptr_eq(&new, c) { - slots.push((i, new)); + match (self.update(&mut new, nested), clip_path) { + (None, _) if Arc::ptr_eq(&new, c) => {} + (None, _) => slots.push((i, new)), + (Some(placement), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, placement)), (1, new)]), + (Some(placement), _) => slots.push((i, Arc::new(self.clip_group(new, placement)))), } continue; } @@ -816,16 +847,10 @@ impl Nested<'_> { }); let (new, clip) = (self.make)(c, held); let mut new = Arc::new(new); - self.update(&mut new, true); + let _ = self.update(&mut new, true); match (clip, clip_path) { (ClipTo::Keep, _) => slots.push((i, new)), - (ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => { - let mut path = Node::clone(path); - if let NodeKind::Path { path: p, .. } = &mut path.kind { - *p = clip; - } - slots.extend([(0, Arc::new(path)), (1, new)]); - } + (ClipTo::Path(clip) | ClipTo::File(clip), Some(path)) if i == 1 => slots.extend([(0, with_clip(path, clip)), (1, new)]), (ClipTo::File(clip), None) if plain => reclipped = Some((clip, new)), (ClipTo::Path(clip) | ClipTo::File(clip) | ClipTo::Nest(clip), _) => { let group = self.clip_group(new, clip); @@ -833,11 +858,30 @@ impl Nested<'_> { } (ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new), (ClipTo::Off, _) => slots.push((i, new)), + // The file's clip group stays `n`, and the placement's clip goes around it. + (ClipTo::Both { file: Some(file), placement }, Some(path)) if i == 1 => { + slots.extend([(0, with_clip(path, file)), (1, new)]); + around = Some(placement); + } + (ClipTo::Both { file: Some(file), placement }, None) if plain => { + reclipped = Some((file, new)); + around = Some(placement); + } + (ClipTo::Both { file: None, placement }, Some(_)) if i == 1 => { + declipped = Some(new); + around = Some(placement); + } + (ClipTo::BothInside { file, placement }, Some(path)) if i == 1 => { + let group = self.clip_group(new, file); + slots.extend([(0, with_clip(path, placement)), (1, Arc::new(group))]); + } + (ClipTo::Both { file, placement }, _) => slots.push((i, self.clip_both(new, file, placement))), + (ClipTo::BothInside { file, placement }, _) => slots.push((i, self.clip_both(new, Some(file), placement))), } } } if mask.is_none() && slots.is_empty() && declipped.is_none() && reclipped.is_none() { - return; + return None; } let reclipped = reclipped.map(|(clip, object)| (Arc::new(self.clipping_path(clip)), object)); let n = Arc::make_mut(n); @@ -849,14 +893,14 @@ impl Nested<'_> { if let NodeKind::Group { children, clip } = &mut n.kind { (*children, *clip) = (vec![path, object], true); } - return; + return around; } // A clip group that stays as a plain group of just the object. if let Some(object) = declipped { if let NodeKind::Group { children, clip } = &mut n.kind { (*children, *clip) = (vec![object], false); } - return; + return around; } if let Some(children) = n.children_mut() { for (i, c) in slots { @@ -865,6 +909,17 @@ impl Nested<'_> { } } } + around + } + + /// A clip group of `placement` holding `object`, inside a clip group of `file` when there is a + /// `file` ([`ClipTo::Both`] for an object no clip group holds yet). + fn clip_both(&mut self, object: Arc, file: Option, placement: PathData) -> Arc { + let object = match file { + Some(file) => Arc::new(self.clip_group(object, file)), + None => object, + }; + Arc::new(self.clip_group(object, placement)) } /// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers. @@ -1035,6 +1090,15 @@ fn clip_file_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { Ok(()) } +/// Clipping path `path` (a node) with its path changed to `clip`. +fn with_clip(path: &Node, clip: PathData) -> Arc { + let mut path = Node::clone(path); + if let NodeKind::Path { path: p, .. } = &mut path.kind { + *p = clip; + } + Arc::new(path) +} + /// A clipping path `id` of `path`, unpainted. fn clipping_path(id: NodeId, path: PathData) -> Node { let none = vectorcraft_color::Paint::None; @@ -1130,6 +1194,19 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio ClipTo::File(path) => clip_file_to(d, id, path), ClipTo::Nest(path) => new_clip_group(d, id, path), ClipTo::Off => unclip(d, id), + ClipTo::Both { file, placement } => { + match file { + Some(file) => clip_file_to(d, id, file)?, + None => unclip(d, id)?, + } + // The file's clip group (or the plain group left of it) takes the placement's clip. + let group = d.parent_of(id).ok_or(EngineError::NoNode(id))?; + clip_image_to(d, group, placement) + } + ClipTo::BothInside { file, placement } => { + clip_image_to(d, id, placement)?; + new_clip_group(d, id, file) + } }) } diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index b780f14c09..43bdf180a7 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -490,6 +490,118 @@ fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() { assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept"); } +/// [`two_plates`] on a 20 × 20 pt page, its composite drawn through the left half of it (same +/// length, so the plates stay where the header says). +fn two_plates_square() -> Vec { + let text = String::from_utf8(two_plates()).unwrap(); + let text = text.replace("0 0 20 10", "0 0 20 20").replace("20 10 scale", "20 20 scale"); + text.replacen("10 10 lineto 0 10 lineto", "10 20 lineto 0 20 lineto", 1).into_bytes() +} + +/// The bounds (width, height) of the clipping path of clip group `g`. +fn clip_size(doc: &Document, g: NodeId) -> (f64, f64) { + let NodeKind::Group { children, clip: true } = &doc.node(g).unwrap().kind else { panic!("a clip group: {:?}", doc.node(g).unwrap().kind) }; + let b = children[0].geometric_bounds().expect("a clipping path"); + (b.width(), b.height()) +} + +#[test] +fn placement_options_clip_a_dcs_image_inside_its_files_clipping_path() { + let dir = temp_dir("placement-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let square = write(&dir, "square.eps", &two_plates_square()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); + let depth = |doc: &Document| std::iter::successors(Some(id), |n| doc.parent_of(*n)).count(); + let mut first = None; + // The square file fills the 20 × 10 pt placement and is cut to it; the file's own clipping + // path, the left half of its page, still clips the image inside that, on every relink. + for _ in 0..2 { + s.execute("links.relink", &json!({ "ids": [id.0], "path": square })).unwrap(); + let doc = &s.doc().unwrap().doc; + let file = doc.parent_of(id).expect("the file's clip group"); + let (w, h) = clip_size(doc, file); + assert!((w - 10.0).abs() < 1e-6 && (h - 20.0).abs() < 1e-6, "the file's clipping path: {w} × {h}"); + let placement = doc.parent_of(file).expect("the placement's clip group"); + let (w, h) = clip_size(doc, placement); + assert!((w - 20.0).abs() < 1e-6 && (h - 10.0).abs() < 1e-6, "the placement's clip: {w} × {h}"); + assert_eq!(*first.get_or_insert(depth(doc)), depth(doc), "no group is added on a later relink"); + } + // An update of the file keeps both. + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(first, Some(depth(doc))); + let file = doc.parent_of(id).unwrap(); + assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path"); + assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip"); + // The file loses its clipping path and gains it again: the same two groups, no more. + let unclipped = String::from_utf8(two_plates_square()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + for _ in 0..2 { + std::fs::write(&square, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(first, Some(depth(doc)), "the file's group stays, without its clip"); + let plain = doc.parent_of(id).unwrap(); + assert!(!matches!(doc.node(plain).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's group is not clipped"); + assert!((clip_size(doc, doc.parent_of(plain).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip stays"); + std::fs::write(&square, two_plates_square()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(first, Some(depth(doc)), "the file's clip group again, at the same depth"); + let file = doc.parent_of(id).unwrap(); + assert!((clip_size(doc, file).0 - 10.0).abs() < 1e-6, "the file's clipping path again"); + assert!((clip_size(doc, doc.parent_of(file).unwrap()).1 - 10.0).abs() < 1e-6, "the placement's clip"); + } +} + +#[test] +fn placement_options_clip_a_dcs_image_in_a_symbol_inside_its_files_clipping_path() { + let dir = temp_dir("placement-clip-symbol"); + let path = write(&dir, "pic.eps", &two_plates()); + let square = write(&dir, "square.eps", &two_plates_square()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image"); + let id = row["id"].as_u64().unwrap(); + s.execute("links.placementOptions", &json!({"ids": [id], "preserve": "fill", "clip": true})).unwrap(); + // The clip groups around the symbol's image, innermost first. + let clips = |s: &Session| { + fn find(n: &Node, id: u64, around: &mut Vec<(f64, f64)>) -> bool { + if n.id.0 == id { + return true; + } + let Some(children) = n.children() else { return false }; + if !children.iter().any(|c| find(c, id, around)) { + return false; + } + if let NodeKind::Group { clip: true, .. } = n.kind + && let Some(b) = children[0].geometric_bounds() + { + around.push((b.width(), b.height())); + } + true + } + let mut around = vec![]; + let doc = &s.doc().unwrap().doc; + assert!(find(&doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art, id, &mut around)); + around + }; + for _ in 0..2 { + s.execute("links.relink", &json!({ "ids": [id], "path": square })).unwrap(); + let around = clips(&s); + assert_eq!(around.len(), 2, "the file's clip group inside the placement's: {around:?}"); + // In the symbol's own units: the file's path as wide as the placement is tall, half as + // wide as it is tall, and the placement twice as wide as it is tall. + let ((fw, fh), (pw, ph)) = (around[0], around[1]); + assert!((fh - 2.0 * fw).abs() < 1e-6 && (fw - ph).abs() < 1e-6, "the file's clipping path: {around:?}"); + assert!((pw - 2.0 * ph).abs() < 1e-6, "the placement's clip: {around:?}"); + } +} + #[test] fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() { let dir = temp_dir("params"); diff --git a/docs/mcp.md b/docs/mcp.md index f756bc8c97..520341267d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6512,7 +6512,7 @@ file's composite is drawn through clips the image (a clipping group, its path un clipping path kept as the same area), and `file.place` answers the image's id as `image`, since the Links commands take the image and not the group. `links.update` and `links.relink` clip the image by the file's clipping path again (a plain group holding just the image becomes its clipping group, a new clipping group holds it when no group does, and it goes inside a clipping mask made in the -document, whose path is kept), unless Placement Options clip the image, the same way for the images in symbols, graph designs, patterns and opacity masks; when the new file has no +document, whose path is kept), the same way for the images in symbols, graph designs, patterns and opacity masks. When Placement Options also clip the image, the file's clipping group goes inside a clipping group of the placement's clip (the clipping group holding just the file's one, or a clipping group holding just the image that is not the file's, takes that clip, else a new one does), and a later update or relink changes the same two groups; when the new file has no clipping path, a clipping group holding just the image whose path is the old file's clipping path loses that path and stays as a plain group of the image, with its id, name, visibility and transparency (one whose path was given the even-odd fill rule stays as it is). The link shows as modified when a plate file of its own changes From 702b863306a82dccc299b072fb5a9dafb3ae9cf8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:34:40 -0800 Subject: [PATCH 18/35] .ai import: reads Kinsoku, Kinsoku Shori Type, Burasagari and Bunri-Kinshi Paragraphs in a modern .ai text document now keep their East Asian line breaking settings: the Kinsoku set (None, Hard or Soft), Kinsoku Shori Type, Burasagari and Bunri-Kinshi. Each comes from the paragraph, else its paragraph style or a style that one is based on, else the document's defaults, as the other paragraph settings do. A value that cannot be read takes the paragraph style's value. A paragraph whose Kinsoku set is a custom set takes its paragraph style's set, and the import warns that custom Kinsoku sets were left out. .ai export does not write these settings. --- crates/eps/src/import/ate.rs | 178 ++++++++++++++++++++++++++- crates/eps/src/import/native/plan.rs | 6 +- docs/target-app-parity.md | 1 + 3 files changed, 182 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 8d01b141d0..28994527a7 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -26,8 +26,8 @@ use std::collections::{BTreeMap, HashMap}; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - CharStyle, HyphenationOptions, JustificationOptions, Justify, LineCap, LineJoin, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, - PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, + Burasagari, CharStyle, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LineCap, LineJoin, Node, NodeId, NodeKind, + ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, }; use vectorcraft_geom::{Affine, PathData, Point, Rect}; @@ -405,6 +405,42 @@ struct Para { tabs: Vec, /// The paragraph has more than [`MAX_TABS`] stops: those past them were left out. tabs_truncated: bool, + /// Key 27: the Kinsoku set, `/nil` for None, else the index of one of the document's sets + /// ([`Texts::kinsoku_sets`]). `None`: the default. + kinsoku: Option, + /// Key 27 names a set of the document that isn't Hard or Soft: the paragraph takes the set it + /// would have without it. + kinsoku_custom: bool, + /// Key 25: Kinsoku Shori Type, 0 Push In First, 1 Push Out First, 2 Push Out Only. `None`: the + /// default. + kinsoku_type: Option, + /// Key 24: Burasagari, 0 None, 1 Regular, 2 Force. `None`: the default. + burasagari: Option, + /// Key 23: Bunri-Kinshi. + bunri_kinshi: bool, +} + +/// The name of the document's predefined Hard Kinsoku set (key 0 of its entry in the document's +/// Kinsoku sets). +const KINSOKU_HARD: &str = "PhotoshopKinsokuHard"; +/// The name of the document's predefined Soft Kinsoku set. +const KINSOKU_SOFT: &str = "PhotoshopKinsokuSoft"; +/// The warning when a paragraph's Kinsoku set is one VectorCraft doesn't have. +pub(crate) const KINSOKU_SETS_LEFT_OUT: &str = "custom Kinsoku sets were left out: their paragraphs take the set their paragraph style has"; + +/// The Kinsoku sets of text document `doc` (the list at key 0 of key 4 of its resources), in +/// order, each by its name (key 0 of the set): Hard or Soft, `None` for any other set. Those past +/// [`MAX_SHEETS`] are left out. +fn kinsoku_sets(doc: &Val) -> Vec> { + let list = doc.get("0").and_then(|d| d.get("4")).and_then(|s| s.get("0")).map(Val::list).unwrap_or_default(); + list.iter() + .take(MAX_SHEETS) + .map(|s| match s.get("0").and_then(|r| r.get("0")).and_then(Val::str) { + Some(KINSOKU_HARD) => Some(Kinsoku::Hard), + Some(KINSOKU_SOFT) => Some(Kinsoku::Soft), + _ => None, + }) + .collect() } /// Most tab stops read for one paragraph. @@ -542,6 +578,9 @@ pub(super) struct Texts { /// The patterns that paint characters, by the name of the art style that gives each /// ([`Self::with_patterns`]). patterns: HashMap, + /// The document's Kinsoku sets (key 4 of its resources), in order, each by its name (key 0): + /// Hard or Soft, `None` for any other set. + kinsoku_sets: Vec>, } impl Texts { @@ -611,6 +650,7 @@ impl Texts { para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), patterns: HashMap::new(), + kinsoku_sets: kinsoku_sets(&doc), }) } @@ -749,7 +789,37 @@ impl Texts { chain.extend(self.para_sheets.first().and_then(|s| s.get("5"))); let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.para_defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num).filter(|v| v.abs() < 1e5).unwrap_or(0.0); + // The East Asian settings: the first value along the chain that can be read, so one that + // can't be read takes the paragraph style's. + let levels = || chain.iter().copied().chain(std::iter::once(&self.para_defaults)); + let choice = |k: &str| { + levels().find_map(|s| match s.get(k) { + Some(Val::Num(v)) if *v == 0.0 || *v == 1.0 || *v == 2.0 => Some(*v as u8), + _ => None, + }) + }; + let set = |v: &Val| match v { + Val::Name(n) if n == "nil" => Some(Some(Kinsoku::None)), + Val::Num(i) if *i >= 0.0 && i.fract() == 0.0 => self.kinsoku_sets.get(*i as usize).copied(), + _ => None, + }; Para { + kinsoku: levels().find_map(|s| s.get("27").and_then(set).flatten()), + kinsoku_custom: levels().find_map(|s| s.get("27").and_then(set)).is_some_and(|k| k.is_none()), + kinsoku_type: choice("25").map(|v| match v { + 0 => KinsokuShoriType::PushInFirst, + 1 => KinsokuShoriType::PushOutFirst, + _ => KinsokuShoriType::PushOutOnly, + }), + burasagari: choice("24").map(|v| match v { + 0 => Burasagari::None, + 1 => Burasagari::Standard, + _ => Burasagari::Forced, + }), + bunri_kinshi: levels().find_map(|s| match s.get("23") { + Some(Val::Bool(b)) => Some(*b), + _ => None, + }) == Some(true), justify: pick("0").and_then(Val::num).map_or(0, |v| v.clamp(0.0, 6.0) as u8), rtl: pick("33").and_then(Val::num) == Some(1.0), first_indent: num("1"), @@ -1264,6 +1334,11 @@ impl Story { self.tabs_left_out.get() } + /// Does a paragraph have a Kinsoku set VectorCraft doesn't have ([`Para::kinsoku_custom`])? + pub(super) fn kinsoku_sets_left_out(&self) -> bool { + self.paras.iter().any(|(_, p)| p.kinsoku_custom) + } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the @@ -1425,6 +1500,10 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4), ..JustificationOptions::default() }, + kinsoku: p.kinsoku.unwrap_or_default(), + kinsoku_type: p.kinsoku_type.unwrap_or_default(), + burasagari: p.burasagari.unwrap_or_default(), + bunri_kinshi: p.bunri_kinshi, ..ParaStyle::default() } } @@ -1496,6 +1575,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. @@ -1520,6 +1600,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; assert_eq!(auto(b""), JustificationOptions::default().auto_leading); @@ -1543,6 +1624,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; @@ -1561,12 +1643,93 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; assert_eq!(own(b""), d); assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d); } + /// The document's Kinsoku sets are known by their names, in order: the predefined Hard and + /// Soft sets, in either string form, and any other set as one VectorCraft doesn't have. + #[test] + fn the_documents_kinsoku_sets_are_known_by_name() { + let utf16: Vec = b"(\xfe\xff".iter().copied().chain(KINSOKU_SOFT.bytes().flat_map(|c| [0, c])).chain(*b")").collect(); + let doc = read( + &[ + b"/0 << /4 << /0 [ << /0 << /0 (PhotoshopKinsokuHard) /5 << /4 1 >> >> >> << /0 << /0 ".as_slice(), + &utf16, + b" >> >> << /0 << /0 (My Set) >> >> << /0 << >> >> ] >> >>", + ] + .concat(), + ) + .unwrap(); + assert_eq!(kinsoku_sets(&doc), [Some(Kinsoku::Hard), Some(Kinsoku::Soft), None, None]); + assert_eq!(kinsoku_sets(&read(b"/0 << >>").unwrap()), []); + } + + /// Keys 23 to 27 are the paragraph's East Asian settings: Bunri-Kinshi (23), Burasagari (24: + /// 0 None, 1 Regular, 2 Force), Kinsoku Shori Type (25: 0 Push In First, 1 Push Out First, 2 + /// Push Out Only) and the Kinsoku set (27: `/nil` for None, else the index of one of the + /// document's sets). A value that can't be read, and a set VectorCraft doesn't have, take the + /// paragraph style's, or else the document's. + #[test] + fn a_paragraph_keeps_its_east_asian_settings() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/27 0 /25 2 /24 0 /23 false").unwrap(), + char_sheets: vec![], + // [Normal Paragraph Style] has the Soft set; the second style has Force burasagari. + para_sheets: vec![read(b"/5 << /27 1 >>").unwrap(), read(b"/5 << /24 2 /23 true >>").unwrap()], + frames: vec![], + patterns: HashMap::new(), + kinsoku_sets: vec![Some(Kinsoku::Hard), Some(Kinsoku::Soft), None], + }; + let para = |run: &[u8]| texts.para(Some(&read(run).unwrap())); + let settings = |run: &[u8]| { + let p = para_style(¶(run), 1.0); + (p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi) + }; + let normal = (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::None, false); + assert_eq!(settings(b"/5 << >>"), normal); + assert_eq!(settings(b"/5 << /27 /nil /25 0 /24 1 /23 true >>"), (Kinsoku::None, KinsokuShoriType::PushInFirst, Burasagari::Standard, true)); + assert_eq!(settings(b"/5 << /27 0 /25 1 /24 2 >>"), (Kinsoku::Hard, KinsokuShoriType::PushOutFirst, Burasagari::Forced, false)); + // The paragraph style it is based on (key 6), then [Normal Paragraph Style]. + assert_eq!(settings(b"/5 << >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, true)); + assert_eq!(settings(b"/5 << /23 false >> /6 1"), (Kinsoku::Soft, KinsokuShoriType::PushOutOnly, Burasagari::Forced, false)); + // A set VectorCraft doesn't have takes the paragraph style's, and the paragraph says so. + assert_eq!(settings(b"/5 << /27 2 >>"), normal); + assert!(para(b"/5 << /27 2 >>").kinsoku_custom); + assert!(!para(b"/5 << /27 0 >>").kinsoku_custom && !para(b"/5 << >>").kinsoku_custom); + // The story warns of it (the importer's warning). + assert!(story("あ\r", &[0.0], ¶(b"/5 << /27 2 >>")).kinsoku_sets_left_out()); + assert!(!story("あ\r", &[0.0], ¶(b"/5 << /27 1 >>")).kinsoku_sets_left_out()); + for unreadable in [ + b"/5 << /27 3 >>".as_slice(), + b"/5 << /27 -1 >>", + b"/5 << /27 0.5 >>", + b"/5 << /27 (PhotoshopKinsokuHard) >>", + b"/5 << /27 /Hard >>", + b"/5 << /25 3 >>", + b"/5 << /25 1.5 >>", + b"/5 << /25 -1 >>", + b"/5 << /24 /nil >>", + b"/5 << /24 true >>", + b"/5 << /23 1 >>", + b"/5 << /23 /nil >>", + ] { + assert_eq!(settings(unreadable), normal, "{}", String::from_utf8_lossy(unreadable)); + assert!(!para(unreadable).kinsoku_custom, "{}", String::from_utf8_lossy(unreadable)); + } + // Without any of the keys, VectorCraft's defaults. + let bare = Texts { para_defaults: Val::Dict(BTreeMap::new()), para_sheets: vec![], kinsoku_sets: vec![], ..texts }; + let p = para_style(&bare.para(Some(&read(b"/5 << >>").unwrap())), 1.0); + let d = ParaStyle::default(); + assert_eq!((p.kinsoku, p.kinsoku_type, p.burasagari, p.bunri_kinshi), (d.kinsoku, d.kinsoku_type, d.burasagari, d.bunri_kinshi)); + } + /// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key /// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is /// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13. @@ -1581,6 +1744,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let tabs = |own: &[u8], scale: f64| { para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) @@ -1648,6 +1812,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], } .para(None); let mut s = story(" 80 mm ", &[0.0], ¶); @@ -1696,6 +1861,7 @@ mod tests { para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), patterns: HashMap::new(), + kinsoku_sets: vec![], }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); @@ -1714,6 +1880,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); @@ -1738,6 +1905,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let roman = |defaults: &[u8], run: &[u8]| texts(defaults).style(Some(&read(run).unwrap())).standard_vertical_roman; assert!(roman(b"/35 1", b"/6 << >>"), "the document's defaults"); @@ -1769,6 +1937,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let stroke = |run: &[u8]| { let s = char_style(&texts.style(Some(&read(run).unwrap())), 1.0, Affine::IDENTITY); @@ -1811,6 +1980,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], } .with_patterns(patterns) } @@ -1894,6 +2064,7 @@ mod tests { para_sheets: vec![], frames: vec![read(b"/0 << /0 [ 0 0 ] /2 << /0 0 >> >>").unwrap()], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let text = |kerns: &[u8]| -> TextObject { let node = texts(kerns).story(0).unwrap().node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap(); @@ -1961,6 +2132,7 @@ mod tests { para_sheets: vec![], frames: vec![], patterns: HashMap::new(), + kinsoku_sets: vec![], }; let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).direction; assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); @@ -2009,6 +2181,7 @@ mod tests { para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], patterns: HashMap::new(), + kinsoku_sets: vec![], }; texts.frame(0) } @@ -2667,6 +2840,7 @@ mod tests { para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], patterns: HashMap::new(), + kinsoku_sets: vec![], }; texts.frame(0).unwrap() } diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index eaa50b6bbf..005b89397e 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -59,7 +59,7 @@ pub(super) fn make_slots( to_doc: Affine, warn: &mut Vec, ) -> Vec> { - let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false); + let (mut taller, mut deeper, mut tabs_left_out, mut kinsoku_left_out) = (0, 0, false, false); let made = infos .iter() .enumerate() @@ -68,6 +68,7 @@ pub(super) fn make_slots( let story = stories.get(k)?.as_ref()?; let mut node = story.node(NodeId(0), info.frame, t, to_doc)?; tabs_left_out |= story.tabs_left_out(); + kinsoku_left_out |= story.kinsoku_sets_left_out(); let mut region = story.region(t, to_doc); // The first line where the file has it, in the text document's frame (that of a // copied object is its original's, as its lines are). @@ -102,6 +103,9 @@ pub(super) fn make_slots( if tabs_left_out { warn.push(crate::import::ate::TABS_LEFT_OUT.into()); } + if kinsoku_left_out { + warn.push(crate::import::ate::KINSOKU_SETS_LEFT_OUT.into()); + } made } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..43b20f2c80 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1348,6 +1348,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type on a path with right-to-left text that does not fit shows the longest start of its text whose glyphs fit, also where tracking gives a letter a negative advance and a shorter start does not fit; the rest overflows or flows to the next threaded object (VectorCraft's own rule). Without hanging punctuation, and on a straight path or with no spacing, every start is measured without arranging it on the path again. With hanging punctuation, or spacing on a curve, when a letter has a negative advance, at most 32 of the starts that could fit are arranged one by one from the longest; when none of them fits, a binary search over the rest shows a start that fits, which is not always the longest (`overflowing_rtl_type_on_a_path_shows_the_longest_start_that_fits`, `the_longest_rtl_start_on_a_path_is_found_without_arranging_each_start`, `the_longest_rtl_start_on_a_path_is_the_one_each_start_gives` and `rtl_type_on_a_curve_whose_letters_take_the_line_back_is_arranged_a_bounded_number_of_times` in `crates/text/src/tests/bidi.rs`). Right-aligned and centered type on a curve with Spacing is placed by what its glyphs take where the line ends up, so text that fits is not cut off at the end bracket (`on_path_aligned_spaced_text_that_fits_shows_whole` in `crates/text/src/tests.rs`). The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. The Paragraph panel menu's Bunri-Kinshi (`text.setFormat {bunriKinshi}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) keeps inseparable pairs on one line in each paragraph that has it: two em dashes, two ellipses, two two-dot leaders and the two halves of a vertical kana repeat mark (the pairs of W3C JLREQ's class cl-08, VectorCraft's set). Both composers and vertical type follow it, and justification adds no space inside a pair. It works with a Kinsoku Set only, is off by default and is saved only when on; editing the set of pairs is not available. + `.ai` import reads each paragraph's Kinsoku Set (None, Hard or Soft), Kinsoku Shori Type, Burasagari and Bunri-Kinshi from the file's editable text: the paragraph's own setting, else its paragraph style's or a style that one is based on, else the document's default. A paragraph with a custom Kinsoku set takes its paragraph style's set, and the importer warns about it. A value it cannot read takes its paragraph style's value too. `.ai` export does not write these settings. The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. The Paragraph panel menu's Reset Panel (`text.setStyle`, `text.setFormat` and `text.tabs.clear`) gives the selected type's paragraphs, or the paragraphs the Type tool's selection touches, Auto alignment, no indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto direction, Bottom-to-Bottom Leading, the Line-end Punctuation Half Width Mojikumi Set, Regular burasagari, the Hard kinsoku set, Push Out Only, the Every-line Composer, Bunri-Kinshi, Roman Hanging Punctuation and Korean Line Break Anywhere off, no kashidas and no tab stops, as one undo step. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. From 6bd675528390e04386d8c0e40ef7f37a61939e50 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:02:06 -0800 Subject: [PATCH 19/35] 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 f1d2d45f18797ada86b8e676a722172089c35ebd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 23:10:16 -0800 Subject: [PATCH 20/35] i18n: Hungarian has a row for every message missing from all catalogs untranslated_report lists 167 status and error messages that no complete-message catalog translates. hu.tsv gains 114 @msg rows for them: image export and encoding errors, color profiles, color books and swatch exchange files, binary DXF and DWG import, 3D and Revolve limits, and script, zip, line and file errors. Placeholders, parameter names and literal values stay as the English writes them. The other 53 listed messages are left to the open pull requests that already add their rows. --- crates/ui-egui/src/i18n/hu.tsv | 116 +++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8549bd5d63..67fb1b4c66 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7479,3 +7479,119 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő + +# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. +@msg 3D effects require finite coordinates within 100 million points A 3D effektusokhoz 100 millió ponton belüli véges koordináták kellenek +@msg 3D effects require one to ten lights A 3D effektusokhoz egy és tíz közötti számú fény kell +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-színmélység {_1}: 1, 4, 8, 16, 24 vagy 32 bit/pixel lehet +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer A BMP-kódolás sikertelen: {width} × {height} pixel nem fér bele a formátumba vagy a pixelpufferbe +@msg GIF encoding failed: the palette image is malformed A GIF-kódolás sikertelen: a palettás kép hibás szerkezetű +@msg GIF encoding failed: {e} A GIF-kódolás sikertelen: {e} +@msg JPEG colour profile: {e} JPEG-színprofil: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size A JPEG-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg JPEG encoding failed: {e} A JPEG-kódolás sikertelen: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size A PNG-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg PNG encoding failed: the palette image is malformed A PNG-kódolás sikertelen: a palettás kép hibás szerkezetű +@msg PNG encoding failed: {e} A PNG-kódolás sikertelen: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size A PNG-kódolás sikertelen: {width} × {height} pixel nem érvényes képméret +@msg PSD colour profile: {e} PSD-színprofil: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size A PSD-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg Revolve profile contains invalid coordinates Forgatás tengely körül: a profil érvénytelen koordinátákat tartalmaz +@msg Revolve profile is too complex Forgatás tengely körül: a profil túl összetett +@msg Revolve projection is not finite Forgatás tengely körül: a vetület nem véges +@msg Revolve requires finite profile coordinates within 100 million points Forgatás tengely körül: 100 millió ponton belüli véges profilkoordináták kellenek +@msg Revolve surface is too complex; reduce Segments or simplify the profile Forgatás tengely körül: a felület túl összetett; csökkentsd a Szakaszok értékét, vagy egyszerűsítsd a profilt +@msg Revolve surface is too complex; simplify the profile Forgatás tengely körül: a felület túl összetett; egyszerűsítsd a profilt +@msg Revolve visibility cannot split the projected boundary Forgatás tengely körül: a láthatóság számítása nem tudja felosztani a vetített határvonalat +@msg Revolve visibility creates too many fragments Forgatás tengely körül: a láthatóság számítása túl sok darabot hoz létre +@msg Revolve visibility creates too many vector anchors Forgatás tengely körül: a láthatóság számítása túl sok vektoros horgonypontot hoz létre +@msg Revolve visibility exceeds its geometry work limit Forgatás tengely körül: a láthatóság számítása túllépi a geometriai számítások korlátját +@msg Revolve visibility exceeds its spatial index limit Forgatás tengely körül: a láthatóság számítása túllépi a térbeli index korlátját +@msg Revolve visibility has too many boundary crossings Forgatás tengely körül: a láthatóság számításában túl sok a határvonal-keresztezés +@msg Revolve visibility has too many input anchors Forgatás tengely körül: a láthatóság számításában túl sok a bemeneti horgonypont +@msg Revolve visibility requires convex cap sectors Forgatás tengely körül: a láthatóság számításához konvex zárólap-szektorok kellenek +@msg Revolve visibility requires finite projected faces Forgatás tengely körül: a láthatóság számításához véges vetített lapok kellenek +@msg SVG options: {e} SVG-beállítások: {e} +@msg Save for Web settings: {e} Webes mentés beállításai: {e} +@msg TIFF colour profile: {e} TIFF-színprofil: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size A TIFF-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg TIFF encoding failed: {e} A TIFF-kódolás sikertelen: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-színmélység {_1}: 16, 24 vagy 32 bit/pixel lehet +@msg Targa encoding failed: the pixel buffer doesn't match the image size A Targa-kódolás sikertelen: a pixelpuffer nem egyezik a kép méretével +@msg The 3D projection is not finite A 3D vetület nem véges +@msg The 3D shadow is too complex; turn Shadows off or simplify the art A 3D árnyék túl összetett; kapcsold ki az Árnyékok beállítást, vagy egyszerűsítsd a rajzot +@msg The shape contains invalid coordinates Az alakzat érvénytelen koordinátákat tartalmaz +@msg Too many Revolve faces for visibility evaluation Túl sok lap a láthatóság kiértékeléséhez (Forgatás tengely körül) +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} A VectorCraft legfeljebb {ASE_MAX_ENTRIES} színt és csoportot olvas be egy színmintacsere-fájlból, ebben a könyvtárban pedig ennyi van: {entries} +@msg WebP encoding failed: {e} A WebP-kódolás sikertelen: {e} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view`: smallThumb, mediumThumb, largeThumb, smallList vagy largeList lehet +@msg a line can't run from a point to itself egy vonal nem futhat egy pontból önmagába +@msg a line needs at least two points egy vonalhoz legalább két pont kell +@msg a number cannot be read egy szám nem olvasható be +@msg a swatch exchange block is too long egy színmintacsere-blokk túl hosszú +@msg a swatch exchange name is too long egy színmintacsere-név túl hosszú +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels egy {sw} × {sh} méretű kép nem méretezhető át erre: {width} × {height} pixel +@msg a {width} × {height} image is too large egy {width} × {height} méretű kép túl nagy +@msg a {width} × {height} image: {e} egy {width} × {height} méretű kép: {e} +@msg an image could not be written: {e} egy képet nem sikerült kiírni: {e} +@msg an image that couldn't be read was left out kimaradt egy kép, amely nem volt beolvasható +@msg can't open “{name}”: {e} nem nyitható meg: „{name}”: {e} +@msg cannot align zip entries to {align} bytes a zip-bejegyzések nem igazíthatók {align} bájtos határra +@msg cannot open the automation {kind} root `{_1}`: {e} az automatizálás gyökérmappája nem nyitható meg ({kind}): `{_1}`: {e} +@msg cannot read profile: {0} a profil nem olvasható: {0} +@msg color book version {version} isn't supported (only 1) a színkönyv verziója nem támogatott: {version} (csak 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) a színkönyvek nem támogatottak ebben a színtérben: {other} (csak RGB, CMYK és Lab) +@msg file name too long to zip: {_1} a fájlnév túl hosszú a zip-tömörítéshez: {_1} +@msg file name too long to zip: {name} a fájlnév túl hosszú a zip-tömörítéshez: {name} +@msg freeform line {i}: {e} szabad formájú vonal {i}: {e} +@msg freeform point {i}: {e} szabad formájú pont {i}: {e} +@msg give `{key}` or `{other}`, not both `{key}` vagy `{other}`: csak az egyiket add meg, ne mindkettőt +@msg grayscale profiles can't be working spaces yet a szürkeárnyalatos profilok egyelőre nem lehetnek munkaszínterek +@msg input contains NaN or infinite coordinates a bemenet NaN vagy végtelen koordinátákat tartalmaz +@msg input path could not be closed a bemeneti útvonalat nem sikerült lezárni +@msg invalid ICC profile: {0} érvénytelen ICC-profil: {0} +@msg invalid scale {scale} érvénytelen méretarány: {scale} +@msg its localized names take more than {_1} KB a honosított nevei több mint {_1} KB helyet foglalnak +@msg its objects are nested too deep ({e}) az objektumai túl mélyen egymásba ágyazottak ({e}) +@msg its tag table lists the `{_1}` tag more than once a címketáblája többször is felsorolja ezt a címkét: `{_1}` +@msg not a graphic style library: {e} nem grafikaistílus-könyvtár: {e} +@msg not a swatch library (.vcswatches or .gpl) nem színminta-könyvtár (.vcswatches vagy .gpl) +@msg not a swatch library (format `{_1}`) nem színminta-könyvtár (formátum: `{_1}`) +@msg not a swatch library: {e} nem színminta-könyvtár: {e} +@msg not an action set file ({e}) nem műveletkészlet-fájl ({e}) +@msg profile colour space {other} is not RGB, CMYK or Gray a profil színtere nem RGB, CMYK vagy szürkeárnyalatos: {other} +@msg regular expression too large a reguláris kifejezés túl nagy +@msg script file is not UTF-8 text: {value} a parancsfájl nem UTF-8 szöveg: {value} +@msg stop {i}: {e} színátmenet-pont {i}: {e} +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) a színmintacsere-fájl verziója nem támogatott: {major}.{minor} (csak 1.x) +@msg the color book holds more than {ASE_MAX_ENTRIES} colors a színkönyv több mint {ASE_MAX_ENTRIES} színt tartalmaz +@msg the color book is cut short a színkönyv csonka +@msg the color book's names take more than {_1} MB a színkönyv nevei több mint {_1} MB helyet foglalnak +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution a kép mérete {w} × {h} pixel lenne; a raszteres exportálás oldalanként legfeljebb {MAX_RASTER_SIDE} pixelt és összesen legfeljebb {_1} megapixelt enged: csökkentsd a méretarányt vagy a felbontást +@msg the optimised image can't be read back: {e} az optimalizált kép nem olvasható vissza: {e} +@msg the pixel buffer doesn't match the image size a pixelpuffer nem egyezik a kép méretével +@msg the preview: {e} az előnézeti kép: {e} +@msg the request may not read the New Document profiles folder a kérés nem olvashatja az Új dokumentum profiljainak mappáját +@msg the shapes are too degenerate to combine az alakzatok túlságosan elfajultak az egyesítéshez +@msg the shapes cross themselves and each other too often to combine az alakzatok túl sokszor keresztezik önmagukat és egymást az egyesítéshez +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups a színmintacsere-fájl több mint {ASE_MAX_ENTRIES} színt és csoportot tartalmaz +@msg the swatch exchange file is cut short a színmintacsere-fájl csonka +@msg the {_1} plate can't be read: {e} a nyomólemez nem olvasható be ({_1}): {e} +@msg this binary DXF file is damaged or cut short ez a bináris DXF-fájl sérült vagy csonka +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that ez DWG-rajz, amely nem olvasható: mentsd DXF-ként a CAD-alkalmazásból, és azt nyisd meg +@msg too many classes túl sok karakterosztály +@msg too many groups túl sok csoport +@msg too many layers for PSD (at most 32767) túl sok réteg a PSD formátumhoz (legfeljebb 32767) +@msg too many swatch exchange blocks túl sok színmintacsere-blokk +@msg unknown colour profile: {0} ismeretlen színprofil: {0} +@msg {_1} clashes with a built-in profile name {_1}: ütközik egy beépített profil nevével +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} pixel túl nagy a GIF formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást +@msg {_1}: no usable transform to/from sRGB {_1}: nincs használható átalakítás az sRGB színtérbe vagy onnan +@msg {name} is not a CMYK profile {name}: nem CMYK-profil +@msg {name} is not an RGB profile {name}: nem RGB-profil +@msg {path}: could not be read {path}: nem sikerült beolvasni +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pixel túl nagy a JPEG formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pixel túl nagy a Targa formátumhoz (oldalanként legfeljebb 65535 pixel): csökkentsd a felbontást +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pixel túl nagy a PSD formátumhoz (oldalanként legfeljebb {MAX_SIDE} pixel): csökkentsd a felbontást From 831c477ad61cc314f6b341a20df12c96d7561d2d Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:24:58 +0530 Subject: [PATCH 21/35] Symbols: built-in Weather Icons symbol library --- ROADMAP.md | 2 + crates/engine/src/cmd/symbollib.rs | 137 +++++++++++++++++- crates/engine/src/tests/symbollib.rs | 32 +++- .../src/panels/tests_symbol_libraries.rs | 5 +- docs/gaps.md | 13 +- docs/mcp.md | 4 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 10 +- 8 files changed, 188 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 346c532277..dabed3eb94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -124,6 +124,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged. + - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 98b46a5d1a..7ddc6e4bb2 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, LazyLock}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol, SymbolNineSlice}; -use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, Rect, SubPath, shapes}; use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_natural_size}; use super::clipboard::Clipboard; @@ -160,6 +160,7 @@ pub const BUILTIN: &[BuiltinSymbolLibrary] = &[ BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers }, BuiltinSymbolLibrary { id: "inventory-icons", name: "Inventory Icons", make: inventory_icons }, BuiltinSymbolLibrary { id: "skill-icons", name: "Skill Icons", make: skill_icons }, + BuiltinSymbolLibrary { id: "weather-icons", name: "Weather Icons", make: weather_icons }, ]; /// Built-in library `id`, made once. @@ -358,6 +359,140 @@ fn skill_icons() -> SymbolLibrary { SymbolLibrary { name: "Skill Icons".into(), doc } } +/// Weather Icons: sun, moon, cloud, rain, thunder and snow for weather/status art (original, no 9-slice). +fn weather_icons() -> SymbolLibrary { + // 1. Sun: Central circular disk with radial rays. + let mut sun_subpaths = Vec::new(); + sun_subpaths.extend(shapes::ellipse(Rect::new(-8.5, -8.5, 8.5, 8.5)).subpaths); + for i in 0..8 { + let angle = (i as f64) * std::f64::consts::PI / 4.0; + let (sin, cos) = angle.sin_cos(); + let r_in = 11.5; + let r_out = 18.5; + let w = 1.5; + let p1 = Point::new(r_in * cos - w * sin, r_in * sin + w * cos); + let tip = Point::new(r_out * cos, r_out * sin); + let p2 = Point::new(r_in * cos + w * sin, r_in * sin - w * cos); + sun_subpaths.push(SubPath::polyline(&[p1, tip, p2], true)); + } + let sun = PathData::new(sun_subpaths); + + // 2. Moon: Crescent moon shape. + let moon_anchors = vec![ + Anchor { p: Point::new(3.0, -17.0), h_in: Point::new(-2.0, -10.0), h_out: Point::new(-8.0, -17.0), kind: AnchorKind::Corner }, + Anchor { p: Point::new(-16.0, 0.0), h_in: Point::new(-16.0, -9.0), h_out: Point::new(-16.0, 9.0), kind: AnchorKind::Smooth }, + Anchor { p: Point::new(5.0, 16.0), h_in: Point::new(-6.0, 16.0), h_out: Point::new(0.0, 8.0), kind: AnchorKind::Corner }, + Anchor { p: Point::new(-4.0, -1.0), h_in: Point::new(-4.0, 6.0), h_out: Point::new(-4.0, -8.0), kind: AnchorKind::Smooth }, + ]; + let moon = PathData::single(SubPath { anchors: moon_anchors, closed: true }); + + // Helper for cloud contour + let cloud_subpath = |offset_y: f64, scale: f64| -> SubPath { + let mut pts = Vec::new(); + let base_y = 8.0 * scale + offset_y; + pts.push(Point::new(9.0 * scale, base_y)); + pts.push(Point::new(-9.0 * scale, base_y)); + + let c1 = Point::new(-9.0 * scale, 2.0 * scale + offset_y); + let r1 = 6.0 * scale; + let steps_lobe = 8; + for i in 1..=steps_lobe { + let a = std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64); + pts.push(Point::new(c1.x + r1 * a.cos(), c1.y + r1 * a.sin())); + } + + let c2 = Point::new(0.0, -3.0 * scale + offset_y); + let r2 = 9.0 * scale; + let a_start = -2.973; + let a_end = -0.169; + let steps_top = 10; + for i in 1..=steps_top { + let a = a_start + (i as f64) * (a_end - a_start) / (steps_top as f64); + pts.push(Point::new(c2.x + r2 * a.cos(), c2.y + r2 * a.sin())); + } + + let c3 = Point::new(9.0 * scale, 2.0 * scale + offset_y); + let r3 = 6.0 * scale; + for i in 1..steps_lobe { + let a = -std::f64::consts::FRAC_PI_2 + (i as f64) * std::f64::consts::PI / (steps_lobe as f64); + pts.push(Point::new(c3.x + r3 * a.cos(), c3.y + r3 * a.sin())); + } + + SubPath::polyline(&pts, true) + }; + + // 3. Cloud: Merged circular lobes with a flat base. + let cloud = PathData::single(cloud_subpath(0.0, 1.0)); + + // 4. Rain: Cloud outline with angled falling droplet/dash lines. + let mut rain_subpaths = vec![cloud_subpath(-4.0, 0.9)]; + let drops = [(-6.0, 6.0), (0.0, 8.0), (6.0, 6.0)]; + for &(x0, y0) in &drops { + let (dx, dy) = (-2.0, 6.0); + let w = 1.0; + let d = [Point::new(x0 + w, y0), Point::new(x0 + dx + w, y0 + dy), Point::new(x0 + dx - w, y0 + dy), Point::new(x0 - w, y0)]; + rain_subpaths.push(SubPath::polyline(&d, true)); + } + let rain = PathData::new(rain_subpaths); + + // 5. Thunder: Angular zigzag lightning bolt polygon. + let bolt = [(3.0, -18.0), (-8.0, -1.0), (-1.0, -1.0), (-5.0, 18.0), (8.0, 1.0), (1.0, 1.0)]; + let bolt_pts: Vec = bolt.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let thunder = PathData::single(SubPath::polyline(&bolt_pts, true)); + + // 6. Snow: Symmetrical snowflake with hexagonal cross and branching ticks. + let mut snow_pts = Vec::new(); + let r_hub = 3.5; + for k in 0..6 { + let angle = (k as f64) * std::f64::consts::PI / 3.0; + let (u_sin, u_cos) = angle.sin_cos(); + let u = Point::new(u_cos, u_sin); + let v = Point::new(-u_sin, u_cos); + let to_pt = |du: f64, dv: f64| Point::new(du * u.x + dv * v.x, du * u.y + dv * v.y); + + let valley_angle = angle - std::f64::consts::PI / 6.0; + snow_pts.push(Point::new(r_hub * valley_angle.cos(), r_hub * valley_angle.sin())); + + snow_pts.push(to_pt(5.0, -1.0)); + snow_pts.push(to_pt(8.5, -1.0)); + snow_pts.push(to_pt(10.0, -3.0)); + snow_pts.push(to_pt(11.5, -1.0)); + snow_pts.push(to_pt(15.5, -1.0)); + snow_pts.push(to_pt(15.5, -2.5)); + + snow_pts.push(to_pt(18.0, 0.0)); + + snow_pts.push(to_pt(15.5, 2.5)); + snow_pts.push(to_pt(15.5, 1.0)); + snow_pts.push(to_pt(11.5, 1.0)); + snow_pts.push(to_pt(10.0, 3.0)); + snow_pts.push(to_pt(8.5, 1.0)); + snow_pts.push(to_pt(5.0, 1.0)); + } + let snow = PathData::single(SubPath::polyline(&snow_pts, true)); + + let icons = [ + ("Sun", sun, "#f39c12"), + ("Moon", moon, "#5c6bc0"), + ("Cloud", cloud, "#78909c"), + ("Rain", rain, "#3498db"), + ("Thunder", thunder, "#f1c40f"), + ("Snow", snow, "#29b6f6"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in icons { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + let edge = fill.lerp(&Color::BLACK, 0.35); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Weather Icons".into(); + SymbolLibrary { name: "Weather Icons".into(), doc } +} + /// An instance of symbol `name` (what the resource gathering follows). fn instance(name: &str) -> Node { Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) diff --git a/crates/engine/src/tests/symbollib.rs b/crates/engine/src/tests/symbollib.rs index 54591c4658..c5d32e822a 100644 --- a/crates/engine/src/tests/symbollib.rs +++ b/crates/engine/src/tests/symbollib.rs @@ -84,7 +84,7 @@ fn the_builtin_library_is_original_art_generated_in_code() { let mut s = Session::new(); let list = run(&mut s, "symbol.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); - assert_eq!(libs.len(), 5, "{libs:?}"); + assert_eq!(libs.len(), 6, "{libs:?}"); assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn"))); assert_eq!( (libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()), @@ -102,6 +102,10 @@ fn the_builtin_library_is_original_art_generated_in_code() { (libs[4]["id"].as_str(), libs[4]["name"].as_str(), libs[4]["category"].as_str()), (Some("skill-icons"), Some("Skill Icons"), Some("builtIn")) ); + assert_eq!( + (libs[5]["id"].as_str(), libs[5]["name"].as_str(), libs[5]["category"].as_str()), + (Some("weather-icons"), Some("Weather Icons"), Some("builtIn")) + ); let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"})); let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]); @@ -215,6 +219,32 @@ fn map_markers_symbols_add_without_nine_slice() { )); } +#[test] +fn weather_icons_library_lists_and_places() { + let mut s = session(); + let lib = run(&mut s, "symbol.library", json!({"library": "weather-icons"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Sun", "Moon", "Cloud", "Rain", "Thunder", "Snow"]); + let mut placed = Vec::new(); + for (i, name) in names.iter().enumerate() { + let r = run( + &mut s, + "symbol.addFromLibrary", + json!({"library": "weather-icons", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}), + ); + assert_eq!(r["added"], json!([name])); + placed.push(r["placed"].as_u64().unwrap()); + } + let d = doc(&s); + for (i, name) in names.iter().enumerate() { + assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides"); + assert!(matches!( + d.node(NodeId(placed[i])).map(|n| &n.kind), + Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == *name + )); + } +} + #[test] fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index 9c58bc49c7..955b14327a 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -144,7 +144,10 @@ fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() { let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Symbol Libraries"]; let opened: Vec<_> = entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.symbolLibrary")).map(|e| e.params["library"].clone()).collect(); - assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons")]); + assert_eq!( + opened, + [json!("basic-shapes"), json!("ui-frames"), json!("map-markers"), json!("inventory-icons"), json!("skill-icons"), json!("weather-icons")] + ); let has = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(has("window.symbolLibrary.other"), Some(true)); assert_eq!(has("ui.saveSymbolLibrary"), Some(false), "no symbols to save yet"); diff --git a/docs/gaps.md b/docs/gaps.md index 8aea143a47..00969238c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); estimate decremented (5–15 → 4–14 h). | | 2026-11-14 | minor | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups ``, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -93,7 +94,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) | -| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 5–15 | no | [target-app-parity.md](target-app-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 4–14 | no | [target-app-parity.md](target-app-parity.md) | | 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) | | 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) | | 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) | @@ -636,10 +637,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi symbol static drops them; stored in the native format. Color eyedropper for scatter, art and pattern brushes (Hue Shift colorization; samples canvas art); symbol libraries ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol - Libraries Menu open each library in a library panel of its own (five original libraries generated in code, + Libraries Menu open each library in a library panel of its own (six original libraries generated in code, Basic Shapes, UI Frames with default 9-slice guides on Panel, Button and Bar, Map Markers with Pin, - Flag, Crosshair and Checkpoint, Inventory Icons with Sword, Shield, Potion, Key and Gem, and Skill Icons with - Attack, Defend, Heal, Buff and Ult; User Defined; Other + Flag, Crosshair and Checkpoint, Inventory Icons with Sword, Shield, Potion, Key and Gem, Skill Icons with + Attack, Defend, Heal, Buff and Ult, and Weather Icons with Sun, Moon, Cloud, Rain, Thunder and Snow; User Defined; Other Library… reads the symbols of any document VectorCraft opens); a click adds a symbol to the document's Symbols with its Symbol Options and the resources it uses, as one undo step, and dragging one onto the canvas also places it; Save Symbols / Save Symbol Library writes the @@ -671,9 +672,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic - Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; + Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons and Weather Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of rotated copies as matches. -- **Estimate:** 5–15 h. +- **Estimate:** 4–14 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..05ad3abd56 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4331,8 +4331,8 @@ with the same extension and bytes as one there, loads as that User Defined libra ## Symbol libraries -Symbol libraries are read-only sets of symbols: five generated in code (Basic Shapes, UI Frames, Map Markers, -Inventory Icons, Skill Icons), the `.vectorcraft` files in +Symbol libraries are read-only sets of symbols: six generated in code (Basic Shapes, UI Frames, Map Markers, +Inventory Icons, Skill Icons, Weather Icons), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the symbols of any document loaded with `symbol.loadLibrary`. `symbol.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `symbol.library {library}` returns one's symbols (`name`, `size`, `registration`, `exportType`; `library` is an diff --git a/docs/roadmap.md b/docs/roadmap.md index 9fa76dc027..1cf18c3ce3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -165,7 +165,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, six generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, Weather Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..10a8bffd3e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -243,7 +243,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons and Weather Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | @@ -252,7 +252,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons and Weather Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers row menu commands beyond Collect for Export and menus on guide and blend spine rows (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | @@ -516,7 +516,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons and Weather Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, row menu commands beyond Collect for Export (now on every layer, sublayer, group and object row), menus on guide and blend spine rows, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | @@ -559,7 +559,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have six (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons, Weather Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -945,7 +945,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them. - File › Open has a filter for each kind of file it opens other than a document, Graphic Style Libraries (`.vcstyles`, after Swatch libraries) and Perspective grid presets (`.vcperspective`, after Print presets) among them, and Other Library… lists its library filter once. Graph designs are stored in the document, with no file type or filter of their own. Tests: `open_exts_cover_every_readable_format`, `library_open_filters_list_library_formats_first`. - - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes, UI Frames (Panel, Button and Bar with default 9-slice guides), Map Markers (Pin, Flag, Crosshair and Checkpoint, no default 9-slice), Inventory Icons (Sword, Shield, Potion, Key and Gem, no default 9-slice) and Skill Icons (Attack, Defend, Heal, Buff and Ult, no default 9-slice), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. + - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in libraries, Basic Shapes, UI Frames (Panel, Button and Bar with default 9-slice guides), Map Markers (Pin, Flag, Crosshair and Checkpoint, no default 9-slice), Inventory Icons (Sword, Shield, Potion, Key and Gem, no default 9-slice), Skill Icons (Attack, Defend, Heal, Buff and Ult, no default 9-slice) and Weather Icons (Sun, Moon, Cloud, Rain, Thunder and Snow, no default 9-slice), are original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. From 140df22be384e3463fca84658a48436694347b8c Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:45:12 +0530 Subject: [PATCH 22/35] docs: update feature status date and parity revision history for Weather Icons --- docs/gaps.md | 4 ++-- docs/target-app-parity.md | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 00969238c2..880b4d8f46 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,10 +1,10 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (G5: built-in Weather Icons symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow); estimate 5–15 → 4–14 h; previously: 3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence -**Feature evidence last updated:** 2026-11-03. +**Feature evidence last updated:** 2026-11-14. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 10a8bffd3e..3956bfb3fa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (G5: Weather Icons joins built-in symbol libraries (Sun, Moon, Cloud, Rain, Thunder, Snow); scores and estimates unchanged; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -58,6 +58,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); area score and estimate unchanged. | | 2026-11-14 | minor | Pencil and Paintbrush: a closed stroke (Alt at the release, or ending within Close Paths When Ends Are Within of its start) is fitted as the open stroke would be and closed by a straight segment back to its start, instead of curving on past both ends (`pencil_alt_closes_a_stroke_straight_back_to_its_start` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Snap to Point: with Smart Guides off it still pulls drawn and dragged points onto anchors and ruler guides, but draws none of the Smart Guides labels ("anchor") or lines (#7250; `guides::tests::snap_to_point_without_smart_guides_shows_no_guides`). Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | @@ -2379,6 +2380,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); area score and estimate unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged | | 2026-11-13 | minor | File formats: in a `.ai` or EPS opened from its editing data whose layers don't draw as the page even with the drawn art of every live envelope put back, and are used all the same (trusted, their objects that differ coming in as the page's art, or opened alone), art that differs away from an envelope of type no longer costs it its editable type: it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, and where a clip, mask, group appearance, another envelope, a repeat or a blend holds it (#7141); evidence updated; scores and hours unchanged. | From e0fe95325852f6a628a00ac46bfc44fdeb769851 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:05:47 +0530 Subject: [PATCH 23/35] docs: sync symbols area row inventory in target-app-parity --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3956bfb3fa..a534ecb801 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -246,7 +246,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons and Weather Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons and Weather Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | From 55595f0cff387d924e2ce1ab32e0e077d2968ecd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:40:39 -0800 Subject: [PATCH 24/35] i18n: Dutch messages keep the bracketed field names in English Ten nl rows translated the field names inside [subpath, anchor], [x, y, width, height] and [width, height], which a command reads only in English. They now keep the English names, as the other catalogs do. --- crates/ui-egui/src/i18n/nl.tsv | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 730f237173..50995ac702 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5648,7 +5648,7 @@ imageTrace Outline Contour @msg `alignOn` must be a string `alignOn` moet een string zijn @msg `align` is start, center or end `align` is start, center of end @msg `align` must be a string `align` moet een string zijn -@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` moet een array van paren [subpad, anker] zijn +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` moet een array van paren [subpath, anchor] zijn @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` moet ankerindexen bevatten (0, 1, 2…), niet {k} @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` moet ankerindexen bevatten (0, 1, 2…), niet {v} @msg `answer` for this {kind} dialog must be one of: {_1} `answer` voor dit dialoogvenster {kind} moet een van de volgende zijn: {_1} @@ -5713,7 +5713,7 @@ imageTrace Outline Contour @msg `fidelity` must be from {lo} to {hi} pt `fidelity` moet van {lo} tot {hi} pt zijn @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` moet 1 tot 1000 paden van lettertypebestanden bevatten die de laatste zoekopdracht van text.findFontFiles heeft gevonden @msg `focal` and `radius` cannot be used together `focal` en `radius` kunnen niet samen worden gebruikt -@msg `focal` must be an array of [subpath, anchor] pairs `focal` moet een array van paren [subpad, anker] zijn +@msg `focal` must be an array of [subpath, anchor] pairs `focal` moet een array van paren [subpath, anchor] zijn @msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` noemt [{si}, {ai}], en dat is geen anker van het pad @msg `folder` must be an absolute folder path `folder` moet een absoluut mappad zijn @msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` moet een object met astags en waarden zijn, zoals {"wght"} @@ -5795,8 +5795,8 @@ imageTrace Outline Contour @msg `preset` must be a string `preset` moet een string zijn @msg `radius` must be 0 or more `radius` moet 0 of meer zijn @msg `ratio` must be from 0.1 to 0.5 `ratio` moet van 0,1 tot 0,5 zijn -@msg `rect` must be [x, y, width, height] `rect` moet [x, y, breedte, hoogte] zijn -@msg `rect` must be [x, y, width, height] with a positive size `rect` moet [x, y, breedte, hoogte] met een positieve grootte zijn +@msg `rect` must be [x, y, width, height] `rect` moet [x, y, width, height] zijn +@msg `rect` must be [x, y, width, height] with a positive size `rect` moet [x, y, width, height] met een positieve grootte zijn @msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` moet [x0, y0, x1, y1] zijn (kreeg {r}) @msg `relation` must be overlap, inside or outside `relation` moet overlap, inside of outside zijn @msg `replace` must be a string `replace` moet een string zijn @@ -6224,7 +6224,7 @@ imageTrace Outline Contour @msg distance must be > 0 distance moet > 0 zijn @msg dx and dy give the arrow's direction dx en dy geven de richting van de pijl @msg dx and dy must be numbers dx en dy moeten getallen zijn -@msg dxf fitTo must be [width, height], positive dxf fitTo moet [breedte, hoogte] zijn, positief +@msg dxf fitTo must be [width, height], positive dxf fitTo moet [width, height] zijn, positief @msg dxf must be an object of DXF import options dxf moet een object met DXF-importopties zijn @msg dxf scale must be a positive number up to {_1}, not {s} dxf scale moet een positief getal tot en met {_1} zijn, niet {s} @msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: een eenheid zoals mm, cm, in of pt @@ -6380,7 +6380,7 @@ imageTrace Outline Contour @msg instruction {pc} reached with stack depths {seen} and {d} instructie {pc} bereikt met stackdiepten {seen} en {d} @msg invalid `color` ongeldige `color` @msg invalid anchor index {v}: expected a non-negative integer ongeldige ankerindex {v}: verwacht een niet-negatief geheel getal -@msg invalid anchor reference {v}: expected [subpath, anchor] ongeldige ankerverwijzing {v}: verwacht [subpad, anker] +@msg invalid anchor reference {v}: expected [subpath, anchor] ongeldige ankerverwijzing {v}: verwacht [subpath, anchor] @msg invalid angle ongeldige hoek @msg invalid array length ongeldige arraylengte @msg invalid brush: {e} ongeldig penseel: {e} @@ -6489,7 +6489,7 @@ imageTrace Outline Contour @msg missing `{k}` `{k}` ontbreekt @msg missing anchor anker ontbreekt @msg missing anchors ankers ontbreken -@msg missing anchors [[subpath, anchor]…] anchors [[subpad, anker]…] ontbreekt +@msg missing anchors [[subpath, anchor]…] anchors [[subpath, anchor]…] ontbreekt @msg missing array `{key}` array `{key}` ontbreekt @msg missing case case ontbreekt @msg missing color color ontbreekt @@ -6822,9 +6822,9 @@ imageTrace Outline Contour @msg rating must be a whole number from 0 to {MAX_RATING} rating moet een geheel getal van 0 tot {MAX_RATING} zijn @msg rect and {k} contradict each other: give the area or the artboards rect en {k} spreken elkaar tegen: geef het gebied of de tekengebieden @msg rect applies to raster formats, not {_1} rect geldt voor rasterindelingen, niet voor {_1} -@msg rect is [x, y, width, height] in points rect is [x, y, breedte, hoogte] in punten -@msg rect must be [x, y, width, height] rect moet [x, y, breedte, hoogte] zijn -@msg rect must be [x, y, width, height] with a positive width and height rect moet [x, y, breedte, hoogte] met een positieve breedte en hoogte zijn +@msg rect is [x, y, width, height] in points rect is [x, y, width, height] in punten +@msg rect must be [x, y, width, height] rect moet [x, y, width, height] zijn +@msg rect must be [x, y, width, height] with a positive width and height rect moet [x, y, width, height] met een positieve breedte en hoogte zijn @msg rect needs finite numbers and a positive width and height rect heeft eindige getallen en een positieve breedte en hoogte nodig @msg redraw needs id redraw heeft id nodig @msg redraw within must be a finite distance of 0 or more within van redraw moet een eindige afstand van 0 of meer zijn From ffbc5446d53fcabaa2fe37d98520e00688a84ee3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:43:44 -0800 Subject: [PATCH 25/35] i18n: Hungarian translates the index and PDF knockout messages no catalog had Adds Hungarian rows for two messages that reached main after the earlier rows: the `index` whole-number error and the warning for PDF knockout groups with too many overlapping translucent elements. --- crates/ui-egui/src/i18n/hu.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1dfc9e5c70..9fbe7a7275 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7527,6 +7527,7 @@ breakText Glyphs Glifák @msg WebP encoding failed: {e} A WebP-kódolás sikertelen: {e} @msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} @msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `index` must be a whole number `index`: egész szám kell @msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view`: smallThumb, mediumThumb, largeThumb, smallList vagy largeList lehet @msg a line can't run from a point to itself egy vonal nem futhat egy pontból önmagába @msg a line needs at least two points egy vonalhoz legalább két pont kell @@ -7557,6 +7558,7 @@ breakText Glyphs Glifák @msg its localized names take more than {_1} KB a honosított nevei több mint {_1} KB helyet foglalnak @msg its objects are nested too deep ({e}) az objektumai túl mélyen egymásba ágyazottak ({e}) @msg its tag table lists the `{_1}` tag more than once a címketáblája többször is felsorolja ezt a címkét: `{_1}` +@msg knockout groups with too many overlapping translucent elements are written as PDF knockout groups: their translucent elements knock out what they cover fully a túl sok egymást átfedő áttetsző elemet tartalmazó kiütő csoportok PDF-kiütőcsoportként íródnak: az áttetsző elemeik teljesen kiütik azt, amit takarnak @msg not a graphic style library: {e} nem grafikaistílus-könyvtár: {e} @msg not a swatch library (.vcswatches or .gpl) nem színminta-könyvtár (.vcswatches vagy .gpl) @msg not a swatch library (format `{_1}`) nem színminta-könyvtár (formátum: `{_1}`) From 6d7da503745b76be90a7beb2e3b6751b541413aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:17:09 -0800 Subject: [PATCH 26/35] i18n: Hungarian leaves the answers[{i}] message to the script.run answers change #7532 adds a Hungarian row for `answers[{i}]` {e}. Two rows with the same source would fail the catalog's duplicate check once both merge. --- crates/ui-egui/src/i18n/hu.tsv | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0d54229d8..423b004f0f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7528,7 +7528,6 @@ breakText Glyphs Glifák @msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} A VectorCraft legfeljebb {ASE_MAX_ENTRIES} színt és csoportot olvas be egy színmintacsere-fájlból, ebben a könyvtárban pedig ennyi van: {entries} @msg WebP encoding failed: {e} A WebP-kódolás sikertelen: {e} @msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} -@msg `answers[{i}]` {e} `answers[{i}]` {e} @msg `index` must be a whole number `index`: egész szám kell @msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view`: smallThumb, mediumThumb, largeThumb, smallList vagy largeList lehet @msg a line can't run from a point to itself egy vonal nem futhat egy pontból önmagába From 5803d2da4a2f887c4313410c592e230c5b5e760f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:26:28 -0800 Subject: [PATCH 27/35] Fix #7411: a selection grabbed by an anchor snaps it onto other paths With Smart Guides on, a selection the Selection tool grabbed by one of its anchors only landed that anchor on other anchors and centers, or lined its bounds up. The anchor now also lands on the other objects' paths ("path"), and where a path crosses another path or a ruler guide ("intersect"), in the order Direct Selection uses for a dragged anchor. When the anchor lands on a path, the bounds do not also line up with other objects, and a ruler guide nearby does not pull it off the path. A selection grabbed anywhere else keeps lining its bounds up. - Targets::for_move_by_anchor gathers the other art's path segments as well, and Targets::snap_onto_path lands a point on them or on their crossings with each other and with the ruler guides. - MoveSnap::by_anchor uses those targets; the Selection tool picks it when the press grabbed an anchor. Reported by pivot-al. Docs: docs/target-app-parity.md. --- crates/tools/src/guides/targets.rs | 38 ++++++++++++- crates/tools/src/select.rs | 68 +++++++++++++++++------ crates/tools/src/select/tests.rs | 88 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 176 insertions(+), 20 deletions(-) diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index fa31174afa..e3e6abeef6 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -36,7 +36,8 @@ pub struct Targets { pub(super) xs: Vec<(f64, Point, Kind)>, pub(super) ys: Vec<(f64, Point, Kind)>, /// Path segments and their control bounds, which a point lands on ("path"): gathered only for - /// dragged anchors ([`Self::for_anchor_drag`]). + /// dragged anchors ([`Self::for_anchor_drag`]) and selections moved by an anchor + /// ([`Self::for_move_by_anchor`]). pub(super) segments: Vec<(Rect, PathSeg)>, /// Ruler guides, which pull a point into line where they run (Snap to Point). pub(super) rulers: Vec, @@ -170,7 +171,18 @@ impl Targets { /// Guides › Snap to Last Location, a move (not a copy, which has nothing in `exclude`) goes back /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel snaps moves. pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { - let mut t = Self::collect(cx.doc, exclude, None).styled(cx); + Self::moving(cx, exclude, Gather::Bounds) + } + + /// [`Self::for_move`]'s targets for a selection moved by one of its anchors (#7411): the path + /// segments of the art but `exclude` too, which that anchor lands on ([`Self::snap_onto_path`]), + /// as a dragged anchor does with Direct Selection. + pub fn for_move_by_anchor(cx: &ToolContext, exclude: &[NodeId]) -> Self { + Self::moving(cx, exclude, Gather::Segments) + } + + fn moving(cx: &ToolContext, exclude: &[NodeId], gather: Gather) -> Self { + let mut t = Self::collect_inner(cx.doc, exclude, None, None, gather).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } @@ -858,6 +870,21 @@ impl Targets { self.line(from, q).into_iter().chain(self.label(q, "align")).collect() } + /// A point `p` near no target anchor or center landed where two target paths cross + /// ("intersect"), else on the nearest target path ("path"), within `tol`: the point and its + /// label, as [`Self::snap_point`] lands it. The ruler guides of `rulers` count as paths that + /// cross ([`Self::rulers`]). None when no path is in reach, or the targets hold no segments. + pub(crate) fn snap_onto_path(&self, p: Point, tol: f64, rulers: Option<&Targets>) -> Option<(Point, Vec)> { + if self.segments.is_empty() { + return None; + } + if let Some(q) = self.crossing_with(p, tol, rulers.map_or(&self.rulers, |r| &r.rulers)) { + return Some((q, self.label(q, "intersect").into_iter().collect())); + } + let q = self.on_segment(p, tol)?; + Some((q, self.label(q, Kind::Edge.label()).into_iter().collect())) + } + /// The nearest point within `tol` of `p` on a target segment. fn on_segment(&self, p: Point, tol: f64) -> Option { self.segments @@ -872,6 +899,11 @@ impl Targets { /// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing /// nearest `p` within `tol` ("intersect", #1602). fn crossing(&self, p: Point, tol: f64) -> Option { + self.crossing_with(p, tol, &self.rulers) + } + + /// [`Self::crossing`] with the ruler guides `rulers`. + fn crossing_with(&self, p: Point, tol: f64, rulers: &[Ruler]) -> Option { // A ruler guide running past, as a short line through the reach of `p`. let ruler = |r: &Ruler| { let (pos, ..) = r.line_at(p, tol)?; @@ -885,7 +917,7 @@ impl Targets { .iter() .filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol) .map(|(_, c)| *c) - .chain(self.rulers.iter().filter_map(ruler)) + .chain(rulers.iter().filter_map(ruler)) // shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter .take(32) .collect(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 70060ebc2b..76e4a5c977 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -94,6 +94,9 @@ enum State { /// The point that snaps, moving with the pointer: `start`, or where a press on a path's /// stroke or beside its edge took hold of the path ([`hold_point`]). hold: Point, + /// The press grabbed the selection by an anchor (`start`): it snaps as + /// [`MoveSnap::by_anchor`] says. + by_anchor: bool, began: bool, /// Shift-pressed on this selected object: released without a drag, it leaves the /// selection. @@ -247,13 +250,24 @@ pub(crate) struct MoveSnap { impl MoveSnap { pub(crate) fn new(cx: &ToolContext) -> Self { + Self::gather(cx, Targets::for_move) + } + + /// [`Self::new`] for a selection grabbed by one of its anchors: with Smart Guides, that anchor + /// also lands on the other art's paths and where they cross (#7411), as Direct Selection lands + /// an anchor it drags. + pub(crate) fn by_anchor(cx: &ToolContext) -> Self { + Self::gather(cx, Targets::for_move_by_anchor) + } + + fn gather(cx: &ToolContext, for_move: fn(&ToolContext, &[NodeId]) -> Targets) -> Self { let guides = cx.smart_guides && !cx.snap_to_grid; let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) }; Self { bounds: selection_bounds(cx), - targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)), + targets: guides.then(|| for_move(cx, &cx.selection.objects)), points: points(&cx.selection.objects), - copy_targets: guides.then(|| Targets::for_move(cx, &[])), + copy_targets: guides.then(|| for_move(cx, &[])), copy_points: points(&[]), rulers: (guides && cx.snap_to_point).then(|| Targets::rulers(cx)), } @@ -286,6 +300,9 @@ impl MoveSnap { } // The axes (x, y) Smart Guides already put in line with other art. let mut taken = [false; 2]; + // The grabbed anchor landed on a path or a crossing, which a ruler guide does not pull + // it off. + let mut on_path = false; if let (Some(t), Some(b)) = (targets, self.bounds) { // The point the selection was grabbed by, near another object's anchor or centre, // lands on it, rather than the bounds lining up a little beside it (#955). @@ -294,6 +311,13 @@ impl MoveSnap { d = q - start; guides = ov; taken = [true; 2]; + } else if let Some((q, ov)) = t.snap_onto_path(start + d, cx.snap_tol(), self.rulers.as_ref()) { + // Grabbed by an anchor ([`Self::by_anchor`]), it lands on a path near it, or where + // the path crosses another or a ruler guide, instead of lining the bounds up. + d = q - start; + guides = ov; + taken = [true; 2]; + on_path = true; } else { let (adj, ov, axes) = t.snap_rects_axes(&[b + d], cx.snap_tol()); d += adj; @@ -304,7 +328,7 @@ impl MoveSnap { // A ruler guide within reach of the grabbed point wins over lining the bounds up; on an // axis without one that Smart Guides left free, the bounds line up with the ruler guides // (#5746), alongside the Smart Guides. - if let Some(t) = &self.rulers { + if let Some(t) = self.rulers.as_ref().filter(|_| !on_path) { let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.snap_tol(), taken); if adj != Vec2::ZERO { d += adj; @@ -681,6 +705,15 @@ impl SelectionTool { }) } + /// The point a press `ev` grabs the selection by ([`Self::hover_point`]), and whether it is an + /// anchor. Nothing for other pointer events. + fn grabbed_at(cx: &ToolContext, ev: &PointerEvent) -> (Option, bool) { + match ev.kind { + PointerKind::Down => Self::hover_point(cx, ev.pos).map_or((None, false), |(q, label)| (Some(q), label == "anchor")), + _ => (None, false), + } + } + /// On a press, notes the text the pointer is loaded with ([`Self::loaded_at_press`]); a /// release clears it. fn note_loaded_at_press(&mut self, kind: PointerKind) { @@ -913,7 +946,7 @@ impl Tool for SelectionTool { let m = ev.mods; // A press near a centre or an anchor grabs the object by it. self.hover = None; - let grabbed = if matches!(ev.kind, PointerKind::Down) { Self::hover_point(cx, p).map(|(q, _)| q) } else { None }; + let (grabbed, by_anchor) = Self::grabbed_at(cx, ev); let grab = grabbed.unwrap_or(p); if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) { self.hover = Self::hover_point(cx, p); @@ -939,7 +972,7 @@ impl Tool for SelectionTool { { let several = cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty(); let (deselect, key) = if m.shift { (Some(id), None) } else { (None, (several && !m.alt).then_some(id)) }; - self.state = State::Moving { start: p, press: p, hold: center, began: false, deselect, key, exclude: None }; + self.state = State::Moving { start: p, press: p, hold: center, by_anchor: false, began: false, deselect, key, exclude: None }; return vec![]; } // 3. Objects: Cmd/Ctrl-click selects the one under the selected one (cycling). @@ -947,7 +980,8 @@ impl Tool for SelectionTool { && cx.select_behind && let Some(behind) = object_behind(cx, p) { - self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None, exclude: None }; + self.state = + State::Moving { start: p, press: p, hold: p, by_anchor: false, began: false, deselect: None, key: None, exclude: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } let (hit, scope) = match selected_source_hit(cx, p) { @@ -969,17 +1003,17 @@ impl Tool for SelectionTool { // Grabbed by no anchor or centre, a press on a path's stroke or beside its // edge holds the path there, and that point of it snaps (#3701). let hold = if grabbed.is_some() { grab } else { hold_point(cx, &h, p) }; - self.state = State::Moving { start: grab, press: p, hold, began: false, deselect, key, exclude }; + self.state = State::Moving { start: grab, press: p, hold, by_anchor, began: false, deselect, key, exclude }; out } None => { - self.press_nothing(cx, p, grab, m); + self.press_nothing(cx, p, grab, by_anchor, m); vec![] } } } - (PointerKind::Drag, State::Moving { start, press, hold, began, exclude, .. }) => { - self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, began), exclude) + (PointerKind::Drag, State::Moving { start, press, hold, by_anchor, began, exclude, .. }) => { + self.drag_moving(cx, p, m, ev.backquote, (start, press, hold, by_anchor, began), exclude) } (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { self.last_backquote = ev.backquote; @@ -1679,9 +1713,10 @@ impl SelectionTool { /// A press at `p` that hits no object starts a marquee, except where a clipping mask hides /// the art of a selected object (picked in the Layers panel, say): there, without Shift, it /// grabs the selection by that art (at `grab`), as a press on its visible part does. - fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, m: Mods) { + /// `by_anchor`: `grab` is an anchor of it. + fn press_nothing(&mut self, cx: &ToolContext, p: Point, grab: Point, by_anchor: bool, m: Mods) { self.state = if !m.shift && selected_hidden_at(cx.doc, p, cx.selection_hit_options(), &cx.selection.objects).is_some() { - State::Moving { start: grab, press: p, hold: grab, began: false, deselect: None, key: None, exclude: None } + State::Moving { start: grab, press: p, hold: grab, by_anchor, began: false, deselect: None, key: None, exclude: None } } else { State::Marquee { start: p, cur: p, toggle: m.shift, enclosed: false, directional_inside: false } }; @@ -1693,7 +1728,7 @@ impl SelectionTool { p: Point, m: Mods, backquote: bool, - (start, press, hold, began): (Point, Point, Point, bool), + (start, press, hold, by_anchor, began): (Point, Point, Point, bool, bool), exclude: Option<(NodeId, NodeId)>, ) -> Vec { let mut out = vec![]; @@ -1701,6 +1736,7 @@ impl SelectionTool { if p.distance(press) < Self::drag_threshold(cx) { return out; } + let gather = if by_anchor { MoveSnap::by_anchor } else { MoveSnap::new }; // A Shift-drag from a highlighted object of the Global Edit selects it and moves it // with the selection: its snap targets are gathered for the selection with it. self.moving = Some(match exclude { @@ -1708,9 +1744,9 @@ impl SelectionTool { out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 }))); let mut selection = cx.selection.clone(); selection.add(top); - MoveSnap::new(&ToolContext { selection: &selection, ..*cx }) + gather(&ToolContext { selection: &selection, ..*cx }) } - None => MoveSnap::new(cx), + None => gather(cx), }); out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); } @@ -1721,7 +1757,7 @@ impl SelectionTool { } // Shift has already constrained `d`: any change is a snap. self.snapped = (d - unsnapped).hypot() > 1e-9; - self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None, exclude: None }; + self.state = State::Moving { start, press, hold, by_anchor, began: true, deselect: None, key: None, exclude: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); let mut params = json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }); if backquote { diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 63ca697c7d..b7af316571 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -171,6 +171,94 @@ fn a_move_grabbed_by_an_anchor_lands_it_on_another_anchor() { assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y); } +/// A document with the rectangle of [`doc_with_rect`] (100..200 square) and a straight path from +/// `a` to `b` for each pair, and the rectangle's id. +fn doc_with_rect_and_lines(lines: &[(Point, Point)]) -> (vectorcraft_doc::Document, NodeId) { + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + for &(a, b) in lines { + let n = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[a, b], false)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(n, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + } + (d, id) +} + +/// The rectangle of `d` grabbed at `(gx, gy)` (hovered first, so a press near an anchor grabs it) +/// and dragged to `to`, with Smart Guides on or off (`smart`): the point it was grabbed at after the +/// move, and the label shown. +fn grabbed_and_dragged(d: &vectorcraft_doc::Document, id: NodeId, smart: bool, (gx, gy): (f64, f64), to: (f64, f64)) -> (Point, Option) { + let (none, mut picked, p) = (Selection::default(), Selection::default(), paint()); + picked.add(id); + let (before, after) = (ToolContext { smart_guides: smart, ..cx(d, &none, &p) }, ToolContext { smart_guides: smart, ..cx(d, &picked, &p) }); + let mut t = SelectionTool::default(); + t.pointer(&before, &ev(PointerKind::Move, gx, gy)); + t.pointer(&before, &ev(PointerKind::Down, gx, gy)); + let a = t.pointer(&after, &ev(PointerKind::Drag, to.0, to.1)); + let Some(Action::Preview(_, v)) = a.iter().find(|a| matches!(a, Action::Preview(..))) else { panic!("{a:?}") }; + let start = SelectionTool::hover_point(&before, Point::new(gx, gy)).map_or(Point::new(gx, gy), |(q, _)| q); + let label = t.overlays(&after).iter().find_map(|o| match o { + Overlay::Label { text, .. } => Some(text.clone()), + _ => None, + }); + (start + Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), label) +} + +/// #7411: grabbed by an anchor and dropped near a point along another path, away from its anchors +/// and center, the anchor lands on the path ("path") with Smart Guides. A selection grabbed +/// elsewhere, or with Smart Guides off, does not land on the path. +#[test] +fn a_move_grabbed_by_an_anchor_lands_it_on_another_path() { + let (a, b) = (Point::new(300.0, 400.0), Point::new(500.0, 300.0)); + let (d, id) = doc_with_rect_and_lines(&[(a, b)]); + let off_line = |q: Point| ((b - a).cross(q - a) / (b - a).hypot()).abs(); + // Grab the rectangle by its bottom-right corner (200, 200) and drop it half a point beside + // the line, at (430, 335) on it. + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (430.0, 335.5)); + assert!(off_line(corner) < 1e-9, "the corner lands at {corner:?}, {} off the line", off_line(corner)); + assert!((corner.x - 430.0).abs() < 0.5, "{corner:?}"); + assert_eq!(label.as_deref(), Some("path")); + // Grabbed by its fill instead, the pointer stays half a point beside the line. + let (grip, _) = grabbed_and_dragged(&d, id, true, (150.0, 170.0), (430.0, 335.5)); + assert!(off_line(grip) > 0.4, "the grip lands at {grip:?}"); + // With Smart Guides off, the corner stays where it was dragged. + let (corner, _) = grabbed_and_dragged(&d, id, false, (201.0, 201.0), (430.0, 335.5)); + assert!(off_line(corner) > 0.4, "the corner lands at {corner:?}"); +} + +/// #7411: a selection grabbed by an anchor lands it where two other paths cross ("intersect"), and +/// on another object's anchor near the path rather than on the path. +#[test] +fn a_move_grabbed_by_an_anchor_lands_it_where_paths_cross() { + let (d, id) = + doc_with_rect_and_lines(&[(Point::new(300.0, 400.0), Point::new(500.0, 300.0)), (Point::new(460.0, 250.0), Point::new(460.0, 420.0))]); + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (460.4, 320.5)); + assert!(corner.distance(Point::new(460.0, 320.0)) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("intersect")); + // The end of the upright path (460, 250) is an anchor: the corner lands on it. + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (460.5, 251.0)); + assert!(corner.distance(Point::new(460.0, 250.0)) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("anchor")); +} + +/// #7411: a selection grabbed by an anchor lands it where another path crosses a ruler guide +/// ("intersect"), and a ruler guide running past does not pull it off a path it landed on. +#[test] +fn a_move_grabbed_by_an_anchor_lands_it_where_a_path_crosses_a_ruler_guide() { + let (a, b) = (Point::new(300.0, 400.0), Point::new(500.0, 300.0)); + let (mut d, id) = doc_with_rect_and_lines(&[(a, b)]); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 440.0)); + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (440.5, 330.5)); + assert!(corner.distance(Point::new(440.0, 330.0)) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("intersect")); + // Beyond reach of the crossing, the corner stays on the path with the guide 3.7 points beside + // it. + let off_line = |q: Point| ((b - a).cross(q - a) / (b - a).hypot()).abs(); + let (corner, label) = grabbed_and_dragged(&d, id, true, (201.0, 201.0), (436.43, 332.12)); + assert!(off_line(corner) < 1e-9, "the corner lands at {corner:?}"); + assert_eq!(label.as_deref(), Some("path")); +} + /// #3716: a press a few pixels from an anchor grabs the object by that anchor, but the drag /// threshold counts from where the pointer went down. A click that wobbles by a pixel selects /// the object without moving it, instead of jumping the anchor onto the pointer. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..7b701bb398 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1201,7 +1201,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Layer Options for several highlighted rows shows the first row's settings, and OK changes only the settings set in the dialog: each row keeps its own color, Template, Lock, Show, Print, Preview and Dim Images otherwise (`crates/ui-egui/src/dialogs/layer_options.rs` tests). As VectorCraft's own rule, a layer color (`layer.setProps`, `layer.new`, `layer.newSublayer`) or a Live Paint Gap Preview Color (`livePaint.gapOptions`) given as a hex color with an alpha part (`#rrggbbaa` or `#rgba`) is an error and changes nothing, instead of being set without its alpha. Regression tests: `a_layer_color_with_an_alpha_part_is_refused` in `crates/engine/src/tests/layers.rs` and `a_gap_color_with_an_alpha_part_is_refused` in `crates/engine/src/tests/build.rs`. Panel Options › Locate Selected Objects (beyond Illustrator, off by default): when the last selected object changes on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as Locate Object does, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off. -- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). With Smart Guides › Measurement Labels on, the Line Segment tool labels the segment being drawn with its distance, angle and X and Y offsets (`D`, `Angle`, `dX`, `dY`), and the Pen, Curvature and Smart Pen tools label their rubber band the same way when Enable Rubber Band is on (#2983). +- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't, except the anchor the Selection tool grabbed it by). A selection the Selection tool grabs by one of its anchors snaps that anchor onto the other objects' paths ("path"), and where a path crosses another path or a ruler guide ("intersect"), in the order Direct Selection uses for a dragged anchor; when the anchor lands on a path, the selection's bounds do not also line up with other objects (#7411). The archived 2007 [user guide](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=55) (page 49) says Smart Guides align the pointer of a moved object to existing paths. Snapping onto paths only when the selection was grabbed by an anchor is VectorCraft's own rule; a selection grabbed anywhere else still lines its bounds up. Tests: `select::tests::a_move_grabbed_by_an_anchor_lands_it_on_another_path`, `a_move_grabbed_by_an_anchor_lands_it_where_paths_cross` and `a_move_grabbed_by_an_anchor_lands_it_where_a_path_crosses_a_ruler_guide`. With Smart Guides › Measurement Labels on, the Line Segment tool labels the segment being drawn with its distance, angle and X and Y offsets (`D`, `Angle`, `dX`, `dY`), and the Pen, Curvature and Smart Pen tools label their rubber band the same way when Enable Rubber Band is on (#2983). A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only. Snap to Last Location: with Smart Guides on and Preferences › Smart Guides › Snap to Last Location on (off by default, VectorCraft's own choice), a selection moved by dragging with the Selection, Group Selection or Direct Selection tool, Shift too, goes back to where the move began while it is within Snapping Tolerance of it, labeled "last location"; an Alt copy is not pulled back onto its original, and with View › Snap to Grid or Snap to Pixel on the move snaps to the grid or to whole pixels instead (`prefs.set` key `snapToLastLocation`). The public [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) page lists the option as snapping objects back to their previous position; the distance, the label, the copy rule and the grid and pixel rule are VectorCraft's own. Tests: `select::tests::snap_to_last_location_puts_a_move_back_where_it_began`, `select::tests::snap_to_pixel_wins_over_snap_to_last_location`, `direct::tests::snap_to_last_location_puts_a_path_moved_by_its_anchor_back`, `direct::tests::snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor`, `tests_prefs::snap_to_last_location_puts_a_dragged_square_back`. Pixel Preview: with View › Pixel Preview on, the tools use no Smart Guides. Nothing snaps to Smart Guides or shows their guides, and Snap to Point acts as it does with Smart Guides off. View › Smart Guides keeps its setting and applies again when Pixel Preview is turned off. [Work with Smart Guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/work-with-smart-guides.html) says Smart Guides cannot be used while Pixel Preview is on. Tests: `tests::smartguides::pixel_preview_turns_smart_guides_off`, `canvas::tests::pixel_preview_stops_smart_guides_snapping_a_move`. From fe82f68d6ff6c894ae1610734571e1ecc3234044 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:39:50 -0800 Subject: [PATCH 28/35] TIFF: a 16-bit CMYK or gray TIFF with alpha converts at 16 bits Place, Open and relink read a CMYK or grayscale TIFF with an alpha channel through extra_samples_tiff_rgba, which cut every 16-bit sample to its high byte before converting it. A faint ink or a low alpha was lost: cyan 129 at full alpha showed as white, and an alpha of 129 became fully transparent. The samples are now normalized at the depth the file stores them, and only the resulting color and alpha are rounded to 8 bits (a 16-bit alpha to the nearest 8-bit value). Associated alpha is divided out at 16 bits. A pixel whose associated alpha is zero now has no color, instead of keeping its stored color. TIFF 6.0's Associated Alpha Handling section states this arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. --- crates/doc/src/cmyk.rs | 125 ++++++++++++++++++++++---- crates/engine/src/cmd/fileio/tests.rs | 16 ++++ docs/target-app-parity.md | 1 + 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e89d63cb54..ad52eea1a2 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -515,9 +515,10 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), /// and grayscale with extra samples, of which the first is the alpha when the file says so (other -/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be -/// stored together or one plane per channel. `None` for other images and ones larger than -/// [`MAX_CMYK_PIXELS`]. +/// channels are left out). Associated (premultiplied) alpha is divided back out, and a pixel with +/// no associated alpha has no color. 16-bit samples are converted at 16 bits, and only the result +/// is rounded to 8 bits. The samples may be stored together or one plane per channel. `None` for +/// other images and ones larger than [`MAX_CMYK_PIXELS`]. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { use tiff::tags::Tag; let (kind, mut d) = tiff(bytes)?; @@ -535,20 +536,33 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) _ => return None, }; let n = usize::try_from(pixels(w, h)?).ok()?; - let data = read_samples(&mut d, n, samples)?; - let mut out = Vec::with_capacity(n * 4); - for p in data.chunks_exact(samples) { - let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX }; - let a = f32::from(opacity) / 255.0; - let level = |v: u8| { - let v = f32::from(v) / 255.0; - if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v } + let data = read_samples_at_depth(&mut d, n, samples)?; + // Sample `i` of the image, 0..1. + let level_at = |i: usize| match &data { + Samples::Eight(v) => v.get(i).map(|&s| f32::from(s) / 255.0), + Samples::Sixteen(v) => v.get(i).map(|&s| f32::from(s) / 65535.0), + }; + // Sample `i` of the image as 8 bits, a 16-bit sample rounded to the nearest. + let byte_at = |i: usize| match &data { + Samples::Eight(v) => v.get(i).copied(), + Samples::Sixteen(v) => v.get(i).map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)), + }; + let mut out = Vec::with_capacity(n.checked_mul(4)?); + for first in (0..n).map(|p| p * samples) { + let (opacity, a) = if alpha { (byte_at(first + colour).unwrap_or(u8::MAX), level_at(first + colour).unwrap_or(1.0)) } else { (u8::MAX, 1.0) }; + let level = |s: usize| { + let v = level_at(first + s).unwrap_or(0.0); + match extra { + Some(1) if a > 0.0 => (v / a).min(1.0), + // No associated alpha: the color is zero (TIFF 6.0, Associated Alpha Handling). + Some(1) if alpha => 0.0, + _ => v, + } }; - let [r, g, b] = match *p { - [c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]), - [v, ..] if photometric == Some(0) => [1.0 - level(v); 3], - [v, ..] => [level(v); 3], - [] => continue, + let [r, g, b] = match colour { + 4 => rgb([level(0), level(1), level(2), level(3)]), + _ if photometric == Some(0) => [1.0 - level(0); 3], + _ => [level(0); 3], }; out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); out.push(opacity); @@ -767,6 +781,85 @@ mod tests { assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque"); } + /// Five 16-bit samples a pixel: the four inks and one extra sample, which the file's + /// `ExtraSamples` names. + struct Cmyka16; + impl tiff::encoder::colortype::ColorType for Cmyka16 { + type Inner = u16; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::CMYK; + const BITS_PER_SAMPLE: &'static [u16] = &[16; 5]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 5]; + fn horizontal_predict(row: &[u16], result: &mut Vec) { + result.extend_from_slice(row); + } + } + + /// Two 16-bit samples a pixel: gray and one extra sample. + struct GrayExtra16; + impl tiff::encoder::colortype::ColorType for GrayExtra16 { + type Inner = u16; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero; + const BITS_PER_SAMPLE: &'static [u16] = &[16, 16]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2]; + fn horizontal_predict(row: &[u16], result: &mut Vec) { + result.extend_from_slice(row); + } + } + + /// A one-row TIFF of `C`'s pixels `data`, with the ExtraSamples value `extra`. + fn extra_tiff>(extra: u16, data: &[u16]) -> Vec { + let samples = C::BITS_PER_SAMPLE.len(); + let mut file = Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(u32::try_from(data.len() / samples).unwrap(), 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap(); + img.write_data(data).unwrap(); + file.into_inner() + } + + /// 16-bit CMYK with alpha converts its 16-bit inks and alpha, and rounds only the result to 8 + /// bits; the high byte of each sample alone would give other pixels. + #[test] + fn a_16_bit_cmyk_tiff_with_alpha_converts_at_16_bits() { + let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + let read = |extra: u16, data: &[u16]| extra_samples_tiff_rgba(&extra_tiff::(extra, data), naive).expect("CMYKA reads").into_raw(); + // Unassociated: 129 of cyan, opaque; no ink at an alpha of 129; no ink at an alpha of 386. + let straight = [129, 0, 0, 0, 65535, 0, 0, 0, 0, 129, 0, 0, 0, 0, 386]; + assert_eq!(read(2, &straight), [254, 255, 255, 255, 255, 255, 255, 1, 255, 255, 255, 2]); + // Associated: cyan 128 at an alpha of 256 (half cyan); K 255 at an alpha of 511 (about half K). + let premultiplied = [128, 0, 0, 0, 256, 0, 0, 0, 255, 511]; + assert_eq!(read(1, &premultiplied), [128, 255, 255, 1, 128, 128, 128, 2], "divided by the 16-bit alpha"); + } + + /// 16-bit gray with alpha converts at 16 bits as well. + #[test] + fn a_16_bit_grayscale_tiff_with_alpha_converts_at_16_bits() { + let read = |extra: u16| { + // A gray of 129, opaque, then 128 at an alpha of 256. + let bytes = extra_tiff::(extra, &[129, 65535, 128, 256]); + extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with alpha reads").into_raw() + }; + assert_eq!(read(2), [1, 1, 1, 255, 0, 0, 0, 1], "unassociated"); + assert_eq!(read(1), [1, 1, 1, 255, 128, 128, 128, 1], "associated, divided by the 16-bit alpha"); + } + + /// With associated alpha, a pixel whose alpha is zero has no color (TIFF 6.0, Associated + /// Alpha Handling), whatever its color samples hold. + #[test] + fn a_pixel_with_no_associated_alpha_has_no_color() { + use tiff::encoder::{TiffEncoder, colortype::CMYKA8}; + let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; + let mut file = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(1, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap(); + img.write_data(&[255, 0, 0, 0, 0]).unwrap(); + let cmyk = extra_samples_tiff_rgba(&file.into_inner(), naive).expect("CMYKA reads"); + assert_eq!(cmyk.into_raw(), [255, 255, 255, 0], "no ink"); + let gray = extra_samples_tiff_rgba(&extra_tiff::(1, &[50_000, 0]), |_| [0.0; 3]).expect("gray reads"); + assert_eq!(gray.into_raw(), [0, 0, 0, 0], "a gray of zero"); + } + /// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel, /// times 2 planes. #[test] diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index d17af84b66..7374e26e2b 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -186,6 +186,22 @@ fn planar_tiffs_read_as_their_chunky_versions() { } } +/// A 16-bit CMYK TIFF with alpha places with its alpha rounded from 16 bits, planar as chunky. +#[test] +fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() { + let none = vec![0u16; 6]; + let cyan = vec![129u16, 0, 300, 0, 128, 65535]; + let alpha = vec![65535u16, 129, 386, 0, 256, 65535]; + for extra in [1u16, 2] { + let channels = [cyan.clone(), none.clone(), none.clone(), none.clone(), alpha.clone()]; + let chunky = kept(&format!("extra samples {extra}, chunky"), &hand_tiff(5, 16, &[extra], false, &channels)); + let planar = kept(&format!("extra samples {extra}, planar"), &hand_tiff(5, 16, &[extra], true, &channels)); + assert_eq!(planar, chunky, "extra samples {extra}: planar reads as chunky"); + let alphas: Vec = chunky.1.iter().skip(3).step_by(4).copied().collect(); + assert_eq!(alphas, [255, 1, 2, 0, 1, 255], "extra samples {extra}: the alpha, rounded from 16 bits"); + } +} + /// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows /// a strip each, times 5 planes) is refused as too large to draw, without a panic. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..e030e3db3b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1749,6 +1749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). + - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. From 7e63d4ca0c7d6b130c671d8411157ac085d5945c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:48:25 -0800 Subject: [PATCH 29/35] 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 25e35ee5b132125421c90c8a3fe70b1bfb9d75b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:44:30 -0800 Subject: [PATCH 30/35] 3D: effect validation builds each Extrude and Inflate solid once Checking an Extrude & Bevel (both kinds) or Inflate effect built every shape's profile about the profiles' bounds, then built the fill and stroke solids again about the bounds of the regions they paint. A filled shape's profile is its fill's solid, so that solid was built twice. When a stroke reaches past the shape, the first build also used bounds the effect does not draw it about. The check now builds the profile on its own only where no drawn solid is built from it: every shape for Rotate, and for Extrude & Bevel and Inflate a shape with no fill (strokes only, or no paint). A filled shape's solid is built once, about the bounds the effect draws it about. The shadow check is unchanged. Tests count the solids the check builds: an object with a fill and a stroke, a group of two such members, an effect on the fill and the shadow on; a shape with no fill still has its profile built. --- crates/effects/src/solids.rs | 29 ++++++++++++++++++--- crates/effects/src/solids/tests.rs | 42 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ac9de02cad..443459d269 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -223,6 +223,10 @@ pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Ve } fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> { + #[cfg(test)] + if !is_plane(id) { + SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1)); + } let view = view_options(p); match id { EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view), @@ -292,7 +296,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let mut shapes = members(n, label)?; reshape_members(n, before, &mut shapes); let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; - shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?; + check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; return cast_shadow(id, p, &shapes).map(|_| ()); } @@ -309,12 +313,24 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: None => (geometry(&path, before, &ctx), solid_paints(n)), }; let b = path.bounds().ok_or_else(anchors)?; - facets(id, &path, rule, b, p)?; let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; + check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; cast_shadow(id, p, &shapes).map(|_| ()) } +/// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b` +/// build? Only the profiles no solid of [`check_solids`] is built from are built here: every +/// shape's for Rotate, and for Extrude & Bevel and Inflate the profile of a shape with no fill, +/// whose solids are its strokes' outlines or none. A filled shape's profile is its fill's solid, +/// which [`check_solids`] builds once, about the bounds the effect draws it about. +fn check_profiles(id: &str, p: &Value, shapes: &[Profile], b: Rect) -> Result<(), String> { + shapes + .iter() + .filter(|s| is_plane(id) || !s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)))) + .try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())) +} + /// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes` /// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not /// counted. @@ -658,6 +674,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) { thread_local! { /// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests. static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; + /// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and + /// [`built_solids`] have built on this thread, for the tests. + static SOLID_GEOMETRY_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; } /// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from @@ -687,7 +706,11 @@ fn built_solids<'a>( #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); let built = match inflated.as_mut() { - Some(each) => each.next().unwrap_or_else(|| Ok(vec![])), + Some(each) => { + #[cfg(test)] + SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1)); + each.next().unwrap_or_else(|| Ok(vec![])) + } None => facets(id, area, *rule, bounds, p), }; let result = match built { diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index eb9e81bbc0..8222484c20 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -953,6 +953,48 @@ fn an_objects_solids_count_their_faces_together() { } } +/// How many Extrude & Bevel and Inflate solids `check` builds, and its result. +fn geometry_builds(check: impl FnOnce() -> Result<(), String>) -> (usize, Result<(), String>) { + SOLID_GEOMETRY_BUILT.with(|b| b.set(0)); + let result = check(); + (SOLID_GEOMETRY_BUILT.with(std::cell::Cell::get), result) +} + +/// The check builds each solid the effect draws once, about the bounds it draws it about: one for +/// a fill and one for each stroke, on an object, on a group's members and on an appearance item, +/// with the shadow on or off. +#[test] +fn the_check_builds_each_drawn_solid_once() { + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + let n = square_node(); + assert_eq!(geometry_builds(|| validate_solid(&n, id, &json!({}))), (2, Ok(())), "{id}: a fill and a stroke"); + let mut other = square_node(); + other.id = NodeId(2); + let g = Node::group(NodeId(9999), vec![Arc::new(square_node()), Arc::new(other)]); + assert_eq!(geometry_builds(|| validate_solid(&g, id, &json!({}))), (4, Ok(())), "{id}: two members' fills and strokes"); + } + for id in [EXTRUDE, INFLATE] { + let n = square_node(); + assert_eq!(geometry_builds(|| validate_three_d_at(&n, id, &json!({}), (Some(0), 0))), (1, Ok(())), "{id}: on the fill"); + let shadow = json!({"shadow": true}); + assert_eq!(geometry_builds(|| validate_solid(&n, id, &shadow)), (2, Ok(())), "{id}: with the shadow on"); + } +} + +/// A shape with no fill draws no solid of its profile, so the check builds the profile on its own +/// as well as the strokes' solids, and an unpainted shape's profile alone. +#[test] +fn the_check_builds_the_profile_of_a_shape_with_no_fill() { + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + let mut stroked = square_node(); + stroked.appearance.items.retain(|i| matches!(i, AppearanceItem::Stroke(_))); + assert_eq!(geometry_builds(|| validate_solid(&stroked, id, &json!({}))), (2, Ok(())), "{id}: a stroke alone"); + let mut bare = square_node(); + bare.appearance.items.clear(); + assert_eq!(geometry_builds(|| validate_solid(&bare, id, &json!({}))), (1, Ok(())), "{id}: no paint"); + } +} + /// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke. fn polygon_node(id: u64, corners: usize) -> Node { let points: Vec<Point> = (0..corners) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..2a9c13e665 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1273,6 +1273,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Export 3D Object builds each Extrude & Bevel (both kinds) and Inflate solid once: the same build gives the faces that count toward the object's limit and the faces of its mesh, and the meshes match those of a separate build bit for bit. Tests `mesh::tests::a_meshed_solid_is_built_once`, `an_extruded_solid_and_its_mesh_match_the_two_built_apart`, `inflated_solids_and_their_meshes_match_the_two_built_apart`, `solids::tests_mesh::one_build_exports_the_meshes_built_apart`, `a_stroke_past_the_limits_is_left_out_of_the_export`. + - Checking an Extrude & Bevel (both kinds) or Inflate effect builds each solid the effect draws once, about the bounds the effect draws it about; a shape with no fill still has its profile built on its own. Tests `solids::tests::the_check_builds_each_drawn_solid_once`, `the_check_builds_the_profile_of_a_shape_with_no_fill`. - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes graphics on its swept side, shaded with the object's light ([the archived reference, page 185](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185)). By VectorCraft's own rule a graphic wraps around the axis at its own size at every distance from it, narrows toward the silhouette, follows the profile along its length and is hidden where the side faces away (`threeD.mapGraphic`; `revolve::tests_graphics`, `tests::side_faces_lie_on_the_unrolled_surface_and_caps_do_not`, `map_graphic_puts_a_graphic_on_the_side_of_a_revolve`). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). 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 31/35] 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); From e5a881b05f499c1e780c007d799eabee4ff4dd42 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 17:27:09 +0530 Subject: [PATCH 32/35] i18n: French keeps [subpath, anchor] in English --- crates/ui-egui/src/i18n/fr.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 07209a70dd..2396640141 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7567,7 +7567,7 @@ breakText Glyphs Glyphes @msg `alignOn` must be a string `alignOn` doit être une chaîne @msg `align` is start, center or end `align` vaut start, center ou end @msg `align` must be a string `align` doit être une chaîne -@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` doit être une liste de paires [subpath, anchor] @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {k} @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` doit lister des indices de points d’ancrage (0, 1, 2…), pas {v} @msg `answer` for this {kind} dialog must be one of: {_1} `answer` pour cette boîte de dialogue {kind} doit valoir l’une de ces valeurs : {_1} @@ -7631,7 +7631,7 @@ breakText Glyphs Glyphes @msg `fidelity` must be from {lo} to {hi} pt `fidelity` doit être compris entre {lo} et {hi} pt @msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` doit contenir de 1 à 1000 chemins de fichiers de police trouvés par la dernière recherche text.findFontFiles @msg `focal` and `radius` cannot be used together `focal` et `radius` ne peuvent pas être utilisés ensemble -@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [sous-tracé, point d’ancrage] +@msg `focal` must be an array of [subpath, anchor] pairs `focal` doit être une liste de paires [subpath, anchor] @msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` désigne [{si}, {ai}], qui n’est pas un point d’ancrage du tracé @msg `folder` must be an absolute folder path `folder` doit être le chemin absolu d’un dossier @msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` doit être un objet de balises d’axe et de valeurs, comme {"wght"} From 490c8030804cd89e9ce4113b32815d2dc69c1816 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 03:47:27 -0800 Subject: [PATCH 33/35] SVG import: type on a closed path wraps around past the path's start Text on a path of one closed subpath that is anchored to start before the path's start point (text-anchor end or middle, right-to-left start, or a negative startOffset) started at the start point, and text offset past the path's end started at its end. It now wraps around the path, as SVG 2's startOffset attribute describes for a single closed subpath (https://www.w3.org/TR/SVG2/text.html#TextPathElementStartOffsetAttribute): text-anchor end at startOffset 0 ends at the start point. Open paths and paths of several subpaths still start the text at the path's start or end. --- crates/svg/src/import/text.rs | 16 ++++-- crates/svg/tests/it/import_text.rs | 78 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 1a221a69f1..b8aa55373c 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1408,10 +1408,18 @@ impl Ctx { let em = self.font_size(tp); let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; - // Text that would start before the path's beginning starts at it, with all its glyphs - // on the path (SVG does not draw the glyphs that fall off it). - let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0)); - obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None }; + // On a path of one closed subpath, text that starts before the path's start or past its + // end wraps around the path. Otherwise it starts at the path's start or end. + let path = PathData::from_bezpath(&bp); + let wraps = path.subpaths.len() == 1 && path.is_closed(); + let wrap = |s: f64| { + // `rem_euclid` can round a tiny negative value up to 1. + let s = s.rem_euclid(1.0); + if s < 1.0 { s } else { 0.0 } + }; + let start = + Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) }); + obj.kind = TextKind::OnPath { path, start, end: None }; obj.xf = Affine::IDENTITY; } else if vertical { // Type on a vertical path: dy is extra advance, dx shifts across the column. diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 472d7517e4..fdba7e41cb 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -299,6 +299,84 @@ fn text_path_becomes_type_on_a_path() { assert_eq!(t[2].para.justify, Justify::Left); } +#[test] +fn text_on_a_closed_path_wraps_past_the_path_start() { + // A closed path 400 long: text anchored before its start, or offset past its end, wraps + // around the path instead of stopping at its start or end. + let d = open( + r##"<path id="p" d="M50 50 H150 V150 H50 Z" fill="none" stroke="black"/> + <text font-size="20" text-anchor="end"><textPath href="#p">Wave</textPath></text> + <text font-size="20"><textPath href="#p" startOffset="440">Wave</textPath></text> + <text font-size="20" direction="rtl"><textPath href="#p">Wave</textPath></text> + <text font-size="20" text-anchor="middle"><textPath href="#p">Wave</textPath></text> + <text font-size="20"><textPath href="#p" startOffset="-40">Wave</textPath></text> + <text font-size="20"><textPath href="#p" startOffset="-10%">Wave</textPath></text> + <path id="q" d="M50 50 H150 V150 H50 Z M200 50 H300 V150 H200 Z" fill="none" stroke="black"/> + <text font-size="20" text-anchor="end"><textPath href="#q">Wave</textPath></text> + <path id="o" d="M50 50 H150 V150 H50" fill="none" stroke="black"/> + <text font-size="20" text-anchor="end"><textPath href="#o">Wave</textPath></text> + <text font-size="20"><textPath href="#o" startOffset="440">Wave</textPath></text>"##, + ); + let t = texts(&d); + assert_eq!(t.len(), 9); + let start_of = |t: &TextObject| match t.kind { + TextKind::OnPath { start, .. } => start, + _ => panic!("{:?}", t.kind), + }; + let width: f64 = layout(FontDb::global(), t[0]).glyphs.iter().map(|g| g.advance).sum(); + assert!(width > 0.0); + // Ending at the path's start: the text starts its width before the end of the loop. + assert!(close(start_of(t[0]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[0])); + assert!(close(start_of(t[1]), 0.1, 1e-9), "{}", start_of(t[1])); + // Right-to-left text starts at its edge toward the path's end, here the path's start. + assert!(close(start_of(t[2]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[2])); + // Centered on the path's start: half the text before it, half after. + assert!(close(start_of(t[3]), 1.0 - width / 800.0, 1e-6), "{} {width}", start_of(t[3])); + // A negative startOffset, in user units or as a percentage, counts back from the path's end. + assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4])); + assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5])); + // A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's + // start or end. + assert_eq!(start_of(t[6]), 0.0); + assert_eq!(start_of(t[7]), 0.0); + assert_eq!(start_of(t[8]), 1.0); + + // Laid out, the end-anchored text draws every glyph up the left side and ends at the start + // point; the centered text crosses the start point from the left side onto the top. + let lay = layout(FontDb::global(), t[0]); + assert!(!lay.overflow); + assert_eq!(lay.glyphs.len(), 4); + for g in &lay.glyphs { + let o = t[0].xf * g.origin; + assert!(close(o.x, 50.0, 0.05) && o.y > 50.0 && o.y < 150.0, "{o:?}"); + } + let last = lay.glyphs.last().map(|g| t[0].xf * (g.origin + baseline_dir(g.angle) * g.advance)); + assert!(last.is_some_and(|p| close(p.x, 50.0, 0.05) && close(p.y, 50.0, 0.05)), "{last:?}"); + let lay = layout(FontDb::global(), t[3]); + assert!(!lay.overflow); + assert_eq!(lay.glyphs.len(), 4); + let first = t[3].xf * lay.glyphs[0].origin; + assert!(close(first.x, 50.0, 0.05) && first.y > 50.0, "{first:?}"); + let last = t[3].xf * lay.glyphs[3].origin; + assert!(close(last.y, 50.0, 0.05) && last.x > 50.0, "{last:?}"); + + // Text longer than the loop draws its first characters, whatever its anchor. + let d = open( + r##"<path id="s" d="M0 0 H20 V20 H0 Z" fill="none" stroke="black"/> + <text font-size="20" text-anchor="end"><textPath href="#s">Wave Wave Wave</textPath></text>"##, + ); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + assert!(lay.overflow); + assert!(!lay.glyphs.is_empty() && lay.glyphs.len() < 14, "{}", lay.glyphs.len()); + assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::<Vec<_>>()); +} + +/// The unit vector along a glyph's baseline. +fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 { + vectorcraft_geom::Vec2::new(angle.cos(), angle.sin()) +} + #[test] fn per_character_positions_rotation_and_shifts() { let d = open(r#"<text x="10 30 60" y="50" rotate="15 -30" dy="0 -4" font-size="12">abcd</text>"#); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..1d924927e7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1658,6 +1658,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623). SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`). SVG import places right-to-left point type and type on a path as [SVG 2's `text-anchor`](https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties) says: with `direction: rtl`, `start` puts the right edge of the text at its position (on a path, the text ends at `startOffset`) and `end` puts the left edge there. Each line and each absolutely placed chunk is anchored by the `text-anchor` and `direction` of the element holding its first character, a `<tspan>`'s own included, and each line starts at its own `x`. An absolutely placed chunk is anchored by the left and right edges of its glyphs as they are drawn, so a chunk of several right-to-left characters, whose first character is drawn at its right, sits on its anchor too. A letter-spaced right-to-left line keeps the tracking of all its letters inside the advance it anchors (`rtl_point_text_start_and_end_use_directional_edges`, `rtl_type_on_a_path_starts_at_its_directional_edge`, `rtl_letter_spaced_lines_stay_on_their_anchor`, `rtl_lines_use_each_chunk_first_character_direction_and_anchor`, `each_line_starts_at_its_own_x`, `absolutely_placed_chunks_use_their_own_rtl_anchor` and `absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges` in `crates/svg/tests/it/import_text.rs`). + SVG import places type on a path of one closed subpath as [SVG 2's `startOffset` attribute](https://www.w3.org/TR/SVG2/text.html#TextPathElementStartOffsetAttribute) describes: text anchored to start before the path's start point, or offset past its end, wraps around the path, so text with `text-anchor="end"` at `startOffset` 0 ends at the start point, and a negative `startOffset` counts back from the end. Text longer than the loop draws its first characters whatever its anchor. A path of several subpaths, and an open path, still start the text at the path's start or end (`text_on_a_closed_path_wraps_past_the_path_start` in `crates/svg/tests/it/import_text.rs`). SVG import reads the bidi embedding of a `<tspan>`, `<textPath>` or `<a>` inside a `<text>` as [SVG 1.1's bidi section](https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality) describes: with `unicode-bidi: embed` its characters take its `direction` as their Character Direction, an element with `normal` stays in its parent's embedding, and the `<text>` keeps its own paragraph direction, so a run that SVG export writes with `direction` and `unicode-bidi="embed"` opens with its Character Direction again. Character Direction holds one level of embedding, so `bidi-override`, `isolate` and `isolate-override` are imported as embeddings in their `direction`, `plaintext` as an embedding in the direction of its first strong character, and an embedding inside another as an embedding of its own, each with an import warning. With `unicode-bidi: plaintext` on the `<text>`, each of its paragraphs runs, and each chunk is anchored, in the direction of its first strong character (`a_tspan_bidi_embedding_becomes_its_character_direction`, `unicode_bidi_is_not_inherited_and_reads_from_the_cascade` and `unicode_bidi_values_the_text_model_cannot_hold_are_reported` in `crates/svg/tests/it/import_text.rs`; `character_direction_reads_back` in `crates/svg/tests/it/text_layout.rs`). A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does. SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports. From efb31aa51a28c9149a8c27643eda4e6f17a21578 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 04:06:15 -0800 Subject: [PATCH 34/35] PDF and EPS export: gradients name only the spot inks of stops that color something A gradient's Separation or DeviceN space, and EPS's %%DocumentCustomColors, named the spot ink of every linked stop. A stop out of order whose neighboring stretches have no width, a stop at a tint of 0 without process plates, and in PDF a stop whose stretches are fully transparent print no ink, but their spot inks were still declared, so separations got an empty plate. - vectorcraft-color: Gradient::drawn_stops (the rule Document Info uses, moved here) and Gradient::reached_stops (every stop taken as opaque). - PDF: Separation and DeviceN shadings, the Perceptual spot shadings and the spot-image warning name only inks of drawn stops at a tint above 0 or with process plates; other stops are written with no ink, so their process inks and RGB or Lab colors no longer shape the space either. - EPS: the same with reached stops, since EPS writes stops opaque; a transparent spot stop keeps its ink. Output is unchanged when every stop colors part of the gradient and every spot stop has a tint above 0 or process plates. --- crates/color/src/gradient.rs | 43 ++++++++++++++ crates/color/src/gradient/tests.rs | 63 ++++++++++++++++++++ crates/engine/src/cmd/docinfo.rs | 74 +----------------------- crates/eps/src/scene.rs | 21 ++++--- crates/eps/src/tests.rs | 60 +++++++++++++++++++ crates/pdf/src/export/paint.rs | 92 +++++++++++++++++++++--------- crates/pdf/src/tests/output.rs | 54 ++++++++++++++++++ docs/target-app-parity.md | 1 + 8 files changed, 302 insertions(+), 106 deletions(-) diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 54cb50c197..05b7f4839c 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -547,6 +547,49 @@ impl Gradient { None => (Color::BLACK, 1.0), } } + /// Which of its stops color some part of it that draws, by the stretches [`Self::sample`] + /// mixes in, for stops in any order: the pair of stop `k` and stop `k + 1` covers the offsets + /// above every offset of stops `0..=k` up to stop `k + 1`'s, and both its stops count when that + /// stretch has positive width and either stop is above opacity 0. The first stop also counts + /// when it is above opacity 0, since it colors the offsets up to its own, and so does the last + /// stop, since it colors the offsets above every stop's. Any other stop, opaque or not, counts + /// only through a pair: a stop out of order whose pairs have no width (another stop before it + /// reaches past both ends) colors nothing. A freeform gradient draws from its points, so each of + /// its stops counts while any stop is above opacity 0. + pub fn drawn_stops(&self) -> Vec<bool> { + self.stops_with_color(|s| s.opacity > 0.0) + } + /// [`Self::drawn_stops`] with every stop taken as opaque, for output that leaves stop opacity + /// out: the stops that color a stretch of positive width, and the first and last stops. + pub fn reached_stops(&self) -> Vec<bool> { + self.stops_with_color(|_| true) + } + /// [`Self::drawn_stops`] with `shows` telling whether a stop's opacity shows its color. + fn stops_with_color(&self, shows: impl Fn(&GradientStop) -> bool) -> Vec<bool> { + let stops = &self.stops; + if self.kind == GradientKind::Freeform { + let any = stops.iter().any(&shows); + return vec![any; stops.len()]; + } + let mut drawn = vec![false; stops.len()]; + if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) { + *d = shows(first); + } + if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) { + *d |= shows(last); + } + // `f32::max` skips a NaN offset, which the sampler never picks either. + let mut reach = f32::NEG_INFINITY; + for k in 0..stops.len().saturating_sub(1) { + reach = reach.max(stops[k].offset); + let (a, b) = (&stops[k], &stops[k + 1]); + if b.offset > reach && (shows(a) || shows(b)) { + drawn[k] = true; + drawn[k + 1] = true; + } + } + drawn + } /// [`Self::sample_with`] at each of `ts`, which must not decrease. Each search starts where /// the one before it stopped, so the work is the samples plus the stops, not their product. pub fn sample_increasing_with(&self, ts: impl IntoIterator<Item = f32>, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> { diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index cd25753a05..16d8005786 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -66,6 +66,69 @@ fn stop_reach_matches_scanning_the_stops() { } } +/// A gradient's stops color what draws where they color a stretch that draws, for stops in any +/// order: the sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, +/// so an out-of-order stop's pair can have no width. +#[test] +fn drawn_stops_follow_the_sampled_stretches() { + let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; + let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops); + // In order: the clear stop next to the opaque one, not the one after it. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, true, false]); + // Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with + // the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8. + assert_eq!(g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)]).drawn_stops(), vec![false, false, true, true]); + // An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the + // pairs next to the stop at 0.3 have no width and it colors nothing. + let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); + assert_eq!(out_of_order(0.0).drawn_stops(), vec![false; 5]); + // With the stop at 0.8 opaque, the pairs next to it with width are drawn. + assert_eq!(out_of_order(1.0).drawn_stops(), vec![true, true, false, false, false]); + // The last stop colors the offsets above every stop's, even when it is out of order. + assert_eq!(g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)]).drawn_stops(), vec![false, false, true]); + // The first stop colors the offsets up to its own. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![true, false, false]); + // One stop, every stop clear, and no stops at all. + assert_eq!(g(vec![stop(0.5, 1.0)]).drawn_stops(), vec![true]); + assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).drawn_stops(), vec![false, false]); + assert!(g(vec![]).drawn_stops().is_empty()); + // A freeform gradient draws from its points: its stops count while any is above opacity 0. + let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]); + free.kind = GradientKind::Freeform; + assert_eq!(free.drawn_stops(), vec![true, true, true]); + free.stops.iter_mut().for_each(|s| s.opacity = 0.0); + assert_eq!(free.drawn_stops(), vec![false; 3]); +} + +/// Taken as opaque, every stop that colors a stretch of positive width counts, and the first and +/// last stops, whatever their opacity; an out-of-order stop whose pairs have no width does not. +#[test] +fn reached_stops_leave_out_only_stops_without_width() { + let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; + let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops); + // Clear stops in order color their stretches once opaque. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true; 3]); + assert_eq!(g(vec![stop(0.0, 0.0), stop(1.0, 0.0)]).reached_stops(), vec![true, true]); + // The stop at 0.3 sits inside the stretch the stops at 0 and 0.8 cover. + let out_of_order = g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); + assert_eq!(out_of_order.reached_stops(), vec![true, true, false, true, true]); + // A stop at the offset of the stop before it: its pair has no width, the next one does. + assert_eq!(g(vec![stop(0.0, 1.0), stop(0.0, 1.0), stop(1.0, 1.0)]).reached_stops(), vec![true, true, true]); + assert_eq!( + g(vec![stop(0.0, 1.0), stop(0.6, 1.0), stop(0.6, 1.0), stop(0.2, 1.0), stop(1.0, 1.0)]).reached_stops(), + vec![true, true, false, true, true] + ); + assert!(g(vec![]).reached_stops().is_empty()); + // Every pair the sampler picks between the ends has both its stops counted. + let stops: Vec<GradientStop> = [0.1, 0.4, 0.4, 0.2, 0.9, 0.5].iter().map(|&o| stop(o, 1.0)).collect(); + let g = g(stops); + let (reached, reach) = (g.reached_stops(), StopReach::new(&g.stops)); + for t in (5..=95).map(|k| k as f32 / 100.0).filter(|&t| t > 0.1 && t <= 0.9) { + let k = reach.0.partition_point(|r| *r < t); + assert!(reached[k] && reached[k + 1], "pair {k} at {t}: {reached:?}"); + } +} + /// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as /// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one /// offset used to scan them all for each midpoint). diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 4068c2506c..b5ac1c13e5 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -344,44 +344,9 @@ fn spot_swatches(d: &Document) -> HashSet<&str> { first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect() } -/// Which of gradient `g`'s stops color some part of it that draws, by the stretches -/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k` -/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s, -/// and both its stops count when that stretch has positive width and either stop is above opacity -/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its -/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop, -/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width -/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from -/// its points, so each of its stops counts while any stop is above opacity 0. -fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec<bool> { - let stops = &g.stops; - if g.kind == vectorcraft_color::GradientKind::Freeform { - let any = stops.iter().any(|s| s.opacity > 0.0); - return vec![any; stops.len()]; - } - let mut drawn = vec![false; stops.len()]; - if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) { - *d = first.opacity > 0.0; - } - if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) { - *d |= last.opacity > 0.0; - } - // `f32::max` skips a NaN offset, which the sampler never picks either. - let mut reach = f32::NEG_INFINITY; - for k in 0..stops.len().saturating_sub(1) { - reach = reach.max(stops[k].offset); - let (a, b) = (&stops[k], &stops[k + 1]); - if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) { - drawn[k] = true; - drawn[k + 1] = true; - } - } - drawn -} - /// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is -/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the -/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a +/// linked to, or those a gradient's drawn stops ([`vectorcraft_color::Gradient::drawn_stops`]) are +/// linked to, each once, in the order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a /// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not /// linked to one. fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> { @@ -398,7 +363,7 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> { match p { Paint::Solid { swatch, tint, .. } => add(swatch, *tint), Paint::Gradient(g) => { - let drawn = drawn_stops(&g.gradient); + let drawn = g.gradient.drawn_stops(); g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint)); } _ => {} @@ -1662,39 +1627,6 @@ mod tests { assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}"); } - /// A gradient's stops print where they color a stretch that draws, for stops in any order: the - /// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an - /// out-of-order stop's pair can have no width. - #[test] - fn drawn_stops_follow_the_sampled_stretches() { - use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop}; - let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) }; - let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops); - // In order: the clear stop next to the opaque one, not the one after it. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]); - // Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with - // the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]); - // An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the - // pairs next to the stop at 0.3 have no width and it colors nothing. - let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]); - assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]); - // With the stop at 0.8 opaque, the pairs next to it with width are drawn. - assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]); - // The last stop colors the offsets above every stop's, even when it is out of order. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]); - // The first stop colors the offsets up to its own. - assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]); - // One stop, every stop clear, and no stops at all. - assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]); - assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]); - assert!(drawn_stops(&g(vec![])).is_empty()); - // A freeform gradient draws from its points: its stops count while any is above opacity 0. - let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]); - free.kind = GradientKind::Freeform; - assert_eq!(drawn_stops(&free), vec![true, true, true]); - } - /// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object, /// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of /// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 023dbdcbe1..299d130f7d 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -931,18 +931,21 @@ impl<'a> Scene<'a> { /// process inks the stops use, in CMYK order, and the spot inks, in the order the stops name /// them. Paper white adds no ink, a gray stop is black ink, and a spot stop's process plates /// print with its ink. An off-center midpoint is a stop - /// with the inks halfway between its two stops. `None` for a gradient without a spot ink, and, - /// with a warning, for one whose inks can't be kept or that is mixed by the Perceptual method - /// or around the hue wheel; it is then written in process colors. + /// with the inks halfway between its two stops. Stop opacity is left out, so the stops that + /// color a stretch ([`vectorcraft_color::Gradient::reached_stops`]) name the inks, a spot ink + /// only above a tint of 0 or with process plates; the others are written with no ink. `None` for a gradient without a + /// spot ink, and, with a warning, for one whose inks can't be kept or that is mixed by the + /// Perceptual method or around the hue wheel; it is then written in process colors. fn ink_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<(GradientSpace, RawStops)> { let doc = self.doc; let spot_of = |s: &vectorcraft_color::GradientStop| -> Option<String> { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)).map(str::to_string) }; + let reached = g.reached_stops(); // The spot inks, collected up to one more than a DeviceN space can hold. let mut names: Vec<String> = vec![]; - for s in &g.stops { - if let Some(n) = spot_of(s) + for (s, &r) in g.stops.iter().zip(&reached) { + if let Some(n) = spot_of(s).filter(|_| r && (s.tint > 0.0 || s.plates.is_some())) && !names.contains(&n) { names.push(n); @@ -971,9 +974,10 @@ impl<'a> Scene<'a> { }; // Each stop's ink coverage: the four process inks, then one tint per spot ink. let mut inks: Vec<Vec<f32>> = Vec::with_capacity(g.stops.len()); - for s in &g.stops { + for (s, &r) in g.stops.iter().zip(&reached) { let mut v = vec![0.0; 4 + names.len()]; match spot_of(s) { + _ if !r => {} Some(n) => { if let Some(i) = names.iter().position(|m| *m == n) { v[4 + i] = unit(s.tint); @@ -1116,7 +1120,10 @@ impl<'a> Scene<'a> { // keep no swatch links: the warning [`Self::ink_stops`] gives the others. if g.gradient.method.is_hue() && g.gradient.is_spread() && g.gradient.spread == vectorcraft_color::GradientSpread::Reflect { let doc = self.doc; - if g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { + let spot = |s: &vectorcraft_color::GradientStop| { + (s.tint > 0.0 || s.plates.is_some()) && s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot)) + }; + if g.gradient.stops.iter().zip(g.gradient.reached_stops()).any(|(s, r)| r && spot(s)) { self.warn(SPOT_GRADIENTS_HUE); } } diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index e9d6f79521..8547c4d015 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -532,6 +532,66 @@ fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() { assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); } +#[test] +fn gradients_name_only_the_spot_inks_of_stops_that_color_a_stretch() { + let black = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0)); + let no_spot_space = |ps: &str| !ps.contains("(Gold Ink) cvn") && !ps.contains("/DeviceN") && !ps.contains("/Separation"); + for level in [Level::Two, Level::Three] { + // Gold Ink out of order: the stops at 0 and 0.8 cover the stretches next to it, so it + // colors nothing and has no plate. + let d = spot_gradient_doc(vec![black(0.0), black(0.8), spot_stop(0.2, "Gold Ink", GOLD, 1.0), black(0.4), black(1.0)]); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}"); + assert!(no_spot_space(&ps), "{level:?}: {ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings); + checked_page(&ps); + // Without it, Leaf Ink's tints over paper are a Separation shading. + let d = spot_gradient_doc(vec![ + spot_stop(0.0, "Leaf Ink", LEAF, 1.0), + spot_stop(0.8, "Leaf Ink", LEAF, 0.5), + spot_stop(0.2, "Gold Ink", GOLD, 1.0), + paper(0.4), + paper(1.0), + ]); + let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Leaf Ink)"), "{level:?}: {ps}"); + assert!(ps.contains("/VCsp0 [/Separation (Leaf Ink) cvn /DeviceCMYK") && !ps.contains("(Gold Ink) cvn"), "{level:?}: {ps}"); + checked_page(&ps); + // An RGB stop out of order colors nothing either, so it keeps no ink out. + let d = spot_gradient_doc(vec![ + spot_stop(0.0, "Gold Ink", GOLD, 1.0), + spot_stop(0.8, "Gold Ink", GOLD, 0.5), + GradientStop::new(0.2, Color::rgb(0.0, 1.0, 0.0)), + paper(0.4), + paper(1.0), + ]); + let out = export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap(); + let ps = text(&out); + assert!(ps.contains("/VCsp0 [/Separation (Gold Ink) cvn /DeviceCMYK"), "{level:?}: {ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{level:?}: {:?}", out.warnings); + // Gold Ink at a tint of 0 prints no ink. + let d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 0.0), black(1.0)]); + let ps = text(&export(&d, &EpsOptions { level, ..opts(&d) }, None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors"), None, "{level:?}: {ps}"); + assert!(no_spot_space(&ps), "{level:?}: {ps}"); + } + // Gold Ink at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps + // its process plate in the same space. + let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..spot_stop(1.0, "Gold Ink", GOLD, 0.0) }; + let d = spot_gradient_doc(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated]); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert!(ps.contains("/VCdn0 [/DeviceN [/Cyan (Gold Ink) cvn ] /DeviceCMYK") && checked_page(&ps).contains("/C0 [0.3 0] /C1 [0.3 0]"), "{ps}"); + // EPS leaves stop opacity out, so a clear Gold Ink stop between clear stops is written opaque + // and keeps its plate. + let clear = |s: GradientStop| GradientStop { opacity: 0.0, ..s }; + let d = spot_gradient_doc(vec![black(0.0), clear(black(0.2)), clear(spot_stop(0.4, "Gold Ink", GOLD, 1.0)), clear(black(0.6)), black(1.0)]); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors").as_deref(), Some("(Gold Ink)"), "{ps}"); + assert!(ps.contains("/VCdn0 [/DeviceN [/Black (Gold Ink) cvn ] /DeviceCMYK"), "{ps}"); +} + #[test] fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() { let rect = |d: &mut Document, fill: &str, stroke: &str| { diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 77815f93b3..6baffa48a8 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -90,16 +90,20 @@ impl Exporter<'_> { /// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of /// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the /// tint and opacity there are halfway). A gradient that mixes a spot ink with other colors - /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink. + /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink that prints + /// ([`Self::printed_spot`]). fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> { - let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; + let drawn = g.drawn_stops(); + let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?; let tints: Option<Vec<f32>> = g .stops .iter() - .map(|s| match s.swatch.as_deref() { + .zip(&drawn) + .map(|(s, &d)| match s.swatch.as_deref() { + _ if !d => Some(0.0), // A stop that also prints process plates needs a DeviceN space. Some(n) if n == ink && s.plates.is_none() => Some(s.tint), - _ if self.paper_stop(s) => Some(0.0), + _ if self.inkless_stop(s) => Some(0.0), _ => None, }) .collect(); @@ -126,19 +130,22 @@ impl Exporter<'_> { /// the order the stops name them), with midpoints as explicit stops (the tints and opacity /// there are halfway), or for a Perceptual gradient the stops on its curve /// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink; a spot stop with - /// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. `None`, with a - /// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space; - /// the gradient is then written in the colors of its stops. + /// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. Only the stops + /// that color something that draws ([`vectorcraft_color::Gradient::drawn_stops`]) name inks: + /// the others are written with no ink. `None`, with a warning, when a stop is written in RGB or + /// Lab or the spot inks can't share a DeviceN space; the gradient is then written in the colors + /// of its stops. fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option<Vec<(f32, krilla::color::Color, f32)>> { use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process}; let doc = self.doc; fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)) } + let drawn = g.drawn_stops(); // Each spot ink: its swatch's name, then its colorant name and CMYK equivalent. let mut spots: Vec<(&str, (String, [f32; 4]))> = Vec::new(); - for s in &g.stops { - if let Some(n) = spot_of(doc, s) + for (s, &d) in g.stops.iter().zip(&drawn) { + if let Some(n) = self.printed_spot(s, d) && !spots.iter().any(|(m, _)| *m == n) { spots.push((n, self.spot_ink(n)?)); @@ -146,12 +153,15 @@ impl Exporter<'_> { } // Each stop's ink coverage: the four process inks, then one tint per spot ink. let mut inks: Vec<Vec<f32>> = Vec::with_capacity(g.stops.len()); - for s in &g.stops { + for (s, &d) in g.stops.iter().zip(&drawn) { let mut v = vec![0.0; 4 + spots.len()]; match spot_of(doc, s) { + _ if !d => {} Some(n) => { - let i = spots.iter().position(|(m, _)| *m == n)?; - v[4 + i] = s.tint; + // A spot ink that no drawn stop prints ([`Self::printed_spot`]) has no colorant. + if let Some(i) = spots.iter().position(|(m, _)| *m == n) { + v[4 + i] = s.tint; + } if let Some(p) = s.plates { v[..4].copy_from_slice(&p.map(|t| t.clamp(0.0, 1.0))); } @@ -434,18 +444,38 @@ impl Exporter<'_> { !s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && paper(&s.color) } - /// The spot swatch whose tints are every stop of `g` that isn't paper white - /// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has - /// another color. - fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { - let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); - let ink = g.stops.iter().find_map(spot)?; - g.stops.iter().all(|s| (spot(s) == Some(ink) && s.plates.is_none()) || self.paper_stop(s)).then_some(ink) + /// The spot swatch stop `s` prints: the one it is linked to, when the stop colors something + /// that draws (`drawn`, [`vectorcraft_color::Gradient::drawn_stops`]) at a tint above 0 or + /// with process plates ([`vectorcraft_color::GradientStop::plates`]), which print in the same + /// DeviceN space. + fn printed_spot<'s>(&self, s: &'s vectorcraft_color::GradientStop, drawn: bool) -> Option<&'s str> { + s.swatch.as_deref().filter(|n| drawn && (s.tint > 0.0 || s.plates.is_some()) && self.doc.swatch(n).is_some_and(|w| w.spot)) } - /// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop - /// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or - /// written in CMYK or gray? + /// Does stop `s` add no ink to a Separation or DeviceN shading: is it paper white + /// ([`Self::paper_stop`]) or linked to a spot swatch at a tint of 0 without process plates? + fn inkless_stop(&self, s: &vectorcraft_color::GradientStop) -> bool { + let spot = s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); + self.paper_stop(s) || (spot && self.printed_spot(s, true).is_none()) + } + + /// The spot swatch whose tints are every stop of `g` that colors something that draws + /// ([`vectorcraft_color::Gradient::drawn_stops`]) and adds ink ([`Self::inkless_stop`]); `None` + /// when no such stop prints a spot swatch ([`Self::printed_spot`]) or one has another color. + fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { + let drawn = g.drawn_stops(); + let ink = g.stops.iter().zip(&drawn).find_map(|(s, &d)| self.printed_spot(s, d))?; + g.stops + .iter() + .zip(&drawn) + .all(|(s, &d)| !d || (s.swatch.as_deref() == Some(ink) && s.plates.is_none()) || self.inkless_stop(s)) + .then_some(ink) + } + + /// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): does a stop + /// print a spot swatch ([`Self::printed_spot`]), and is every other stop that colors something + /// that draws linked to a spot swatch, paper white ([`Self::paper_stop`]) or written in CMYK or + /// gray? fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool { let doc = self.doc; let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; @@ -453,7 +483,9 @@ impl Exporter<'_> { let inks = |s: &vectorcraft_color::GradientStop| { self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. }) }; - g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s)) + let drawn = g.drawn_stops(); + let mut stops = g.stops.iter().zip(&drawn); + stops.clone().any(|(s, &d)| self.printed_spot(s, d).is_some()) && stops.all(|(s, &d)| !d || spot(s) || inks(s)) } /// Is `g` a Perceptual linear or radial gradient of spot inks, alone or mixed with process @@ -485,16 +517,20 @@ impl Exporter<'_> { let doc = self.doc; let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true); let space = self.separation(ink)?; - // The stops keep their own tints; paper white is 0% of the ink. - let own: Vec<f32> = g.stops.iter().map(|s| if s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect(); + // The stops keep their own tints; paper white, and a stop that colors nothing that draws, + // is 0% of the ink. + let own: Vec<f32> = + g.stops.iter().zip(g.drawn_stops()).map(|(s, d)| if d && s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect(); Some(g.ink_tints(&full, &own)?.into_iter().map(|(o, t, a)| (o, SepColor::new_f32(t, space.clone()).into(), a)).collect()) } - /// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is - /// in process colors, so the spot inks have no plate of their own. + /// Warn when gradient `g`, drawn as an image, has stops that print a spot swatch + /// ([`Self::printed_spot`]): the image is in process colors, so the spot inks have no plate of + /// their own. fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); - if from_stops && self.has_spot_stop(&g.gradient) { + let prints_spot = || g.gradient.stops.iter().zip(g.gradient.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()); + if from_stops && prints_spot() { self.warn( "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index 0ab4d58c18..a15c2aa429 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -278,6 +278,60 @@ fn spot_gradients_keep_a_trace_of_process_ink_at_a_light_stop() { } } +/// [`gold_gradient_pdf`] with spot swatch "Leaf" (C100 Y100) in the document too. +fn gold_and_leaf_gradient_pdf(stops: Vec<GradientStop>, perceptual: bool) -> String { + let mut d = doc(ColorMode::Cmyk, &[]); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(Color::cmyk(0.0, 0.2, 0.8, 0.1)), global: true, spot: true }); + d.swatches.push(Swatch { name: "Leaf".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 1.0, 0.0)), global: true, spot: true }); + let mut g = Gradient::new(GradientKind::Linear, stops); + if perceptual { + g.method = GradientMethod::Perceptual; + } + let paint = Paint::Gradient(Box::new(GradientPaint::new(g))); + let l = d.layers[0].id; + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 30.0)), Appearance::basic(paint, Paint::None, 0.0)); + d.insert(Some(l), 0, n).unwrap(); + String::from_utf8_lossy(&export(&d, &PdfOptions::uncompressed()).unwrap()).into_owned() +} + +#[test] +fn gradients_name_only_the_spot_inks_of_stops_that_color_what_draws() { + let black = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 1.0)) }; + let paper = |offset: f32| GradientStop::new(offset, Color::cmyk(0.0, 0.0, 0.0, 0.0)); + let leaf = |offset: f32, tint: f32| { + let mut s = GradientStop::new(offset, Color::cmyk(1.0, 0.0, 1.0, 0.0).tinted(tint)); + s.swatch = Some("Leaf".into()); + s.tint = tint; + s + }; + let clear_gold = GradientStop { opacity: 0.0, ..gold_stop(0.4, 1.0) }; + for perceptual in [false, true] { + // Gold in a stretch where every stop is clear, and Gold out of order, where the stops at 0 + // and 0.8 cover the stretches next to it: Gold colors nothing, so no Gold plate. + let clear = vec![black(0.0, 1.0), black(0.2, 0.0), clear_gold.clone(), black(0.6, 0.0), black(1.0, 1.0)]; + let out_of_order = vec![black(0.0, 1.0), black(0.8, 1.0), gold_stop(0.2, 1.0), black(0.4, 1.0), black(1.0, 1.0)]; + // Gold at a tint of 0 prints no Gold ink. + let no_tint = vec![gold_stop(0.0, 0.0), black(1.0, 1.0)]; + for (what, stops) in [("clear", clear), ("out of order", out_of_order), ("tint 0", no_tint)] { + let text = gold_gradient_pdf(stops, perceptual); + assert!(!text.contains("/Gold"), "{what}, perceptual {perceptual}: {text}"); + assert!(!text.contains("/DeviceN") && !text.contains("/Separation"), "{what}, perceptual {perceptual}: {text}"); + } + // Without the Gold stop out of order, Leaf's tints over paper are a Separation shading. + let leaf_only = vec![leaf(0.0, 1.0), leaf(0.8, 0.5), gold_stop(0.2, 1.0), paper(0.4), paper(1.0)]; + let text = gold_and_leaf_gradient_pdf(leaf_only, perceptual); + assert!(text.contains("[/Separation/Leaf/") && !text.contains("/Gold") && !text.contains("/DeviceN"), "perceptual {perceptual}: {text}"); + // A clear Gold stop next to an opaque one colors the stretch between them. + let text = gold_gradient_pdf(vec![black(0.0, 1.0), clear_gold.clone(), black(1.0, 1.0)], perceptual); + assert!(text.contains("[/DeviceN[/Black/Gold]"), "perceptual {perceptual}: {text}"); + } + // Gold at a tint of 0 over a 30% cyan plate (a DeviceN gradient read from a PDF) keeps its + // process plate in the same space. + let plated = GradientStop { plates: Some([0.3, 0.0, 0.0, 0.0]), ..gold_stop(1.0, 0.0) }; + let text = gold_gradient_pdf(vec![GradientStop::new(0.0, Color::cmyk(0.3, 0.0, 0.0, 0.0)), plated], false); + assert!(text.contains("[/DeviceN[/Cyan/Gold]"), "{text}"); +} + #[test] fn converting_to_rgb_turns_cmyk_into_rg() { let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..fb208f37aa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1636,6 +1636,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). + PDF and EPS export name a spot ink in a gradient's Separation or DeviceN space, and EPS in `%%DocumentCustomColors`, only when a stop linked to it at a tint above 0, or with process plates, colors part of the gradient (VectorCraft's own rule): a stop out of order whose neighboring stretches have no width, and in PDF a stop whose stretches are fully transparent, adds no ink and no empty plate. EPS writes every stop opaque, so there a transparent spot stop keeps its ink (`crates/pdf/src/tests/output.rs`, `crates/eps/src/tests.rs`). PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`). - `.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. From bfa0c8e15e5fe9810f57e566fee1b817d6117492 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Sat, 14 Nov 2026 04:31:29 -0800 Subject: [PATCH 35/35] Dimensions: an opened file redraws its labels in its own composite fonts When a document opens or is reverted with dimensions whose art is not in its color mode, the dimensions are drawn again before the document's composite fonts are registered. A label whose font names a composite font that another open document defines differently was measured in that other definition, so it was centered off its anchor. The redraw now measures the labels in the document's own composite fonts, in front and in the back. --- crates/engine/src/cmd/compositefonts/tests.rs | 103 ++++++++++++++++++ crates/engine/src/cmd/dimension.rs | 7 +- docs/target-app-parity.md | 1 + 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index 434f2e383a..cac6f4bdcb 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -478,6 +478,75 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// The label of dimension `id` in `doc`. +fn label(doc: &vectorcraft_doc::Document, id: u64) -> vectorcraft_doc::TextObject { + let node = doc.node(NodeId(id)).unwrap(); + node.children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap() +} + +/// The center of label `t`'s measured box on the page. +fn label_center(t: &vectorcraft_doc::TextObject) -> vectorcraft_geom::Point { + t.xf * t.cached_bounds.unwrap().center() +} + +/// Whether every character of label `t` is filled in CMYK. +fn label_in_cmyk(t: &vectorcraft_doc::TextObject) -> bool { + !t.runs.is_empty() && t.runs.iter().all(|r| matches!(r.style.fill.color(), Some(vectorcraft_color::Color::Cmyk { .. }))) +} + +/// The native file of a CMYK document whose composite font `name` sets Roman at full size, with a +/// dimension whose label is set in it and whose label's characters are saved in RGB, so opening +/// the file draws the dimension again → (the file, the dimension's id, the center of its label as +/// drawn in the document's own composite font). +fn dimension_file(name: &str) -> (Vec<u8>, u64, vectorcraft_geom::Point) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "colorMode": "cmyk"})).unwrap(); + let mut full = font(name); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let line = s.execute("shape.line", &json!({"x1": 100, "y1": 100, "x2": 172, "y2": 100})).unwrap()["id"].as_u64().unwrap(); + let id = s + .execute( + "dimension.create", + &json!({"source": {"kind": "segment", "edge": {"node": line, "subpath": 0, "segment": 0}}, + "options": {"units": "in", "precision": 2, "fontFamily": name, "fontSize": 20}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let drawn = label(&doc, id); + assert!(label_in_cmyk(&drawn)); + let center = label_center(&drawn); + let mut label_id = None; + doc.node(NodeId(id)).unwrap().walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) { + label_id = Some(n.id); + } + }); + if let Some(NodeKind::Text(t)) = doc.node_mut(label_id.unwrap()).map(|n| &mut n.kind) { + for r in &mut t.runs { + r.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)); + } + } + (vectorcraft_format::save_file(&doc), id, center) +} + +/// A file whose dimensions are drawn again when it opens (their art is not in the document's color +/// mode) draws their labels in its own composite fonts, not in those of the front document, which +/// defines the label's font name with Roman at half size: each label stays centered on its anchor. +#[test] +fn an_opened_file_draws_its_dimension_labels_in_its_own_composite_fonts() { + let (bytes, id, center) = dimension_file("CF Dimension"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Dimension")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("dimension.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let opened = label(&s.doc().unwrap().doc, id); + assert!(label_in_cmyk(&opened)); + assert!((label_center(&opened) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&opened)); +} + /// Threaded type read from a file flows through its frames in the file's own composite fonts, when /// no open document defines them. #[test] @@ -536,6 +605,40 @@ fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// A document replaced in front (File › Revert) whose dimensions are drawn again draws their labels +/// in its new composite fonts, not in those it had before. +#[test] +fn a_document_replaced_in_front_draws_its_dimension_labels_in_its_own_composite_fonts() { + let (bytes, id, center) = dimension_file("CF Reverted"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Reverted")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + let replaced = label(&s.doc().unwrap().doc, id); + assert!(label_in_cmyk(&replaced)); + assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced)); +} + +/// A document replaced in the back draws its dimension labels in its own composite fonts, while the +/// front document keeps its definition of the same name registered; the labels stay where they are +/// when the document comes to the front. +#[test] +fn a_document_replaced_in_the_back_draws_its_dimension_labels_in_its_own_composite_fonts() { + let (bytes, id, center) = dimension_file("CF Back Dimension"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Dimension")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert_eq!(s.active_index(), Some(1)); + let front = vectorcraft_text::FontDb::global().composite_font("CF Back Dimension").unwrap(); + assert_eq!(front.parts.get(vectorcraft_doc::CharClass::Roman).size, 50.0); + let replaced = label(&s.documents()[0].doc, id); + assert!(label_in_cmyk(&replaced)); + assert!((label_center(&replaced) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&replaced)); + assert!(s.set_active(0)); + let shown = label(&s.doc().unwrap().doc, id); + assert!((label_center(&shown) - center).hypot() < 1e-6, "{:?} vs {center:?}", label_center(&shown)); +} + /// The type of document `index` with id `id`, wherever its tab is. fn text_in(s: &Session, index: usize, id: u64) -> vectorcraft_doc::TextObject { match &s.documents()[index].doc.node(NodeId(id)).unwrap().kind { diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index 44ac08d7ad..183cbd3746 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -495,7 +495,8 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> { /// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved /// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension -/// that can't be redrawn keeps the art it has. +/// that can't be redrawn keeps the art it has. The labels are measured in the document's own +/// composite fonts, also when another document's definitions of the same names are registered. pub(crate) fn conform(doc: &mut Document) { if !doc.dimensions { return; @@ -521,6 +522,10 @@ pub(crate) fn conform(doc: &mut Document) { } } }); + if stale.is_empty() { + return; + } + let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); for (id, d) in stale { // An error leaves this dimension as it was; the others are still redrawn. let _ = replace(doc, id, d); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..eb9d3978d9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -723,6 +723,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. - **Closing a background document tab:** closing a tab other than the current one (`file.close {index}`) keeps the current document current, and its tool keeps its state; closing the current tab still makes its neighbor current. `crates/engine/src/tests/document_close.rs`. - **Replacing a background document's content:** `Session::replace_document` on a tab other than the current one (the replacement File › Revert makes for the current tab) measures the new content's type in that document's own composite fonts at once, so that type has its measured bounds while the document is in the back. When that document becomes the current one unchanged, its type in those fonts is not measured again. `crates/engine/src/cmd/compositefonts/tests.rs`. +- **Dimension labels in an opened document's composite fonts:** when a document opens or is replaced (File › Revert) with dimensions that are drawn again because their art is not in the document's color mode, each label is measured and centered on its anchor in the document's own composite fonts, also when another open document defines the label's font name differently. `crates/engine/src/cmd/compositefonts/tests.rs` (`an_opened_file_draws_its_dimension_labels_in_its_own_composite_fonts`, `a_document_replaced_in_front_draws_its_dimension_labels_in_its_own_composite_fonts`, `a_document_replaced_in_the_back_draws_its_dimension_labels_in_its_own_composite_fonts`). - **Export and Import Preferences (#6435):** an optional VectorCraft feature. The Preferences dialog's Export Preferences… saves the applied preferences to a JSON file (`prefs.export`; the web build downloads it), and Import Preferences… applies such a file (`prefs.import`). The file leaves out the folder preferences. The import checks every value first and changes nothing on an error; it keeps the open document's units and records no undo step. The file format is VectorCraft's own. Tests: `crates/engine/src/tests/prefs_file.rs`, `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`.