From 6b0d1ed1c026782a927d8d43b1e061c1135e1e26 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 17:43:02 -0700 Subject: [PATCH 001/184] Crop Image: trim checks the image's RGBA size before decoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crop Image's trim (object.cropImage {trim: true}) decoded the image within MAX_RASTER_ALLOC (2 GB) and then converted it to RGBA. The decoder holds the decoded image, before conversion, to that allowance, so a gray or 1-bit image within it could take up to four times as much once converted. Trim now reads the image's size from its header first and, when width × height × 4 is over the allowance, fails before anything is decoded, with the check and message Load Texture uses: "an image of W × H pixels is too large to draw". Images within the allowance are trimmed as before. --- crates/engine/src/cmd/menucmds.rs | 15 ++++++++++++--- crates/engine/src/tests_linkspanel.rs | 2 +- crates/engine/src/tests_menucmds.rs | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 465e38851..23e49a039 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -664,9 +664,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false })); } // Print rasters run to gigabytes decoded: the same allowance opening them has. - let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) - .with_guessed_format() - .map_err(|_| bad(C, "the image's pixels can't be read"))?; + let reader = || { + image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .map_err(|_| bad(C, "the image's pixels can't be read")) + }; + // The image's size as RGBA, read from its header, is held to that allowance before anything + // is decoded (#1717). + let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + if u64::from(w) * u64::from(h) * 4 > crate::cmd::fileio::MAX_RASTER_ALLOC { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); + } + let mut reader = reader()?; let mut limits = image::Limits::default(); limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); reader.limits(limits); diff --git a/crates/engine/src/tests_linkspanel.rs b/crates/engine/src/tests_linkspanel.rs index fc0de2a8b..2899ce021 100644 --- a/crates/engine/src/tests_linkspanel.rs +++ b/crates/engine/src/tests_linkspanel.rs @@ -19,7 +19,7 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId { /// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that /// placing and listing read). -fn png_header(w: u32, h: u32) -> Vec { +pub(crate) fn png_header(w: u32, h: u32) -> Vec { let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); let ihdr: Vec = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat(); for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 410e3aac0..22642ec0c 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -2423,6 +2423,24 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() { assert_eq!(bounds(&s, img), b); } +#[test] +fn crop_image_trim_checks_the_rgba_size_before_decoding() { + // 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA + // (4 bytes a pixel) they are just over or just within the 2 GB allowance. + let trim = |w: u32, h: u32| { + let png = super::tests_linkspanel::png_header(w, h); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string() + }; + let over = trim(23_171, 23_170); + assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}"); + // One column fewer is within the allowance: it is decoded, and its missing rows can't be read. + let within = trim(23_170, 23_170); + assert!(within.contains("the image's pixels can't be read"), "{within}"); +} + #[test] fn crop_image_trim_leaves_a_cmyk_image_alone() { // CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error. From 0f987812b0d21e3bcaba7a689ca3170a5cbd3ebb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 06:23:13 -0700 Subject: [PATCH 002/184] Render: check an image's RGBA size before decoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer decoded each image within its 2 GB allowance (512 MB in the web build) and then converted it to RGBA. The decoder holds the decoded image, before conversion, to that allowance, so an image stored with fewer than four bytes a pixel (gray, 1-bit, gray with alpha, or RGB without alpha) could take up to four times as much (4/3 times for RGB) once converted, and as much again as the pixmap drawn from it. The renderer now reads the image's size from its header first. When width × height × 4 is over the allowance it does not decode the image and handles it as one it can't decode: the renderer draws nothing for it, and a raster export warns that an image is missing. Images within the allowance are decoded and drawn as before. --- crates/render/src/paint.rs | 28 ++++++++++++---- crates/render/src/tests.rs | 67 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 427d39d8a..d021a8f27 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -65,20 +65,34 @@ pub fn decode_failures() -> u64 { DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed) } -/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`]. +/// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted +/// in [`decode_failures`]. fn decode(bytes: &[u8]) -> Option { - let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| { - let mut limits = image::Limits::default(); - limits.max_alloc = Some(MAX_DECODE_ALLOC); - r.limits(limits); - r.decode().ok() - }); + let img = decode_within_allowance(bytes).ok(); if img.is_none() { DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } img } +/// `bytes` decoded within [`MAX_DECODE_ALLOC`], counted as RGBA: the image's size is read from its +/// header first, and an image of more than that as RGBA gets a limit error before anything is +/// decoded (#1717). +pub(crate) fn decode_within_allowance(bytes: &[u8]) -> image::ImageResult { + let reader = || -> image::ImageResult<_> { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_DECODE_ALLOC); + r.limits(limits); + Ok(r) + }; + let (w, h) = reader()?.into_dimensions()?; + if u64::from(w) * u64::from(h) > MAX_DECODE_ALLOC / 4 { + return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::InsufficientMemory))); + } + reader()?.decode() +} + /// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels). fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage { let (w, h) = (img.width(), img.height()); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index a5e32ce0d..70b8a4559 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -799,6 +799,73 @@ fn images_take_their_nearest_pixel_without_smoothing() { assert_eq!(greys(false), 0, "nearest: black, then white"); } +/// A PNG that says it is `w` × `h` 1-bit gray pixels (its header and no rows). +fn png_header(w: u32, h: u32) -> Vec { + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let ihdr: Vec = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat(); + for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] { + out.extend((chunk.len() as u32 - 4).to_be_bytes()); + out.extend(chunk); + let mut crc = flate2::Crc::new(); + crc.update(chunk); + out.extend(crc.sum().to_be_bytes()); + } + out +} + +/// A little-endian TIFF that says it is `w` × `h` 8-bit gray pixels in one strip (its header and +/// no pixels). +fn tiff_header(w: u32, h: u32) -> Vec { + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + // (tag, type: 3 SHORT or 4 LONG, value), one value each. + let entries: [(u16, u16, u32); 9] = + [(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)]; + out.extend((entries.len() as u16).to_le_bytes()); + for (tag, kind, value) in entries { + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(1u32.to_le_bytes()); + out.extend(value.to_le_bytes()); + } + out.extend(0u32.to_le_bytes()); + out +} + +/// The decode allowance (2 GB) counts an image's pixels as RGBA, read from its header (#1717): a +/// 1-bit gray image whose 8-bit gray pixels are within it but whose RGBA is over it is not decoded +/// and draws nothing, counted for an export to report; one a column narrower goes on to decoding. +#[test] +fn an_image_too_large_as_rgba_is_not_decoded() { + let over = paint::decode_within_allowance(&png_header(23_171, 23_170)); + assert!(matches!(over, Err(image::ImageError::Limits(_))), "{over:?}"); + let failed = decode_failures(); + assert!(paint::decode_pixmap(&png_header(23_171, 23_170)).is_none()); + assert!(decode_failures() > failed); + // Within the allowance: decoded, and its missing rows can't be read. + let within = paint::decode_within_allowance(&png_header(23_170, 23_170)); + assert!(matches!(within, Err(image::ImageError::Decoding(_))), "{within:?}"); + // A header whose size as RGBA does not fit in 64 bits gets the same limit error. + let huge = paint::decode_within_allowance(&tiff_header(1 << 31, 1 << 31)); + assert!(matches!(huge, Err(image::ImageError::Limits(_))), "{huge:?}"); +} + +/// A gray image within the allowance draws its gray levels. +#[test] +fn a_gray_image_draws_its_gray_levels() { + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + let img = image::GrayImage::from_fn(2, 1, |x, _| image::Luma([if x == 0 { 40 } else { 200 }])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = Document::new(40.0, 20.0); + d.images.insert("gray".into(), ImageBlob::new("image/png", png)); + let id = d.alloc_id(); + let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; + d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }); + assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255])); +} + /// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no /// seam of the white background between them, where shape-by-shape smoothing shows one; the outer /// edges stay smooth. From 9bee299dd5326fb1578d28985949db331971fc01 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:30:26 -0700 Subject: [PATCH 003/184] Render: an export no longer warns about images another thread could not decode A raster export compared the process-wide count of images that could not be decoded before and after it drew, and warned that "an image couldn't be decoded and is missing from the picture" when the count rose. Any other thread that failed to decode an image in that time raised the count too: a second export, the UI thread drawing the canvas, or the canvas, thumbnail and placed-document workers drawing a document with such an image. The export then warned about an image it does not contain. In the engine's tests, options_not_applied_yet_and_document_features_warn failed in 5 of 60 runs of the tests_raster and tests_pdf* tests. The count is now kept per thread. An export reads it on the thread it draws on. That thread decodes every image the export draws that is not already in the shared image store, and an image that failed to decode is never stored. --- crates/engine/src/cmd/fileio/tests_raster.rs | 47 ++++++++++++++++++++ crates/render/src/paint.rs | 16 ++++--- crates/render/src/tests.rs | 17 +++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 53b35204c..110847ed5 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -217,6 +217,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() { } } +#[test] +fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() { + // While another thread keeps exporting a document whose image can't be decoded (two exports at + // once, or the canvas drawing such a document), PNG exports of art with no image report + // nothing, and every export on the other thread still reports its own image. + use std::sync::atomic::{AtomicBool, Ordering}; + use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind}; + let mut broken = vectorcraft_doc::Document::new(40.0, 20.0); + broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); + let im = ImageObject { + key: "broken".into(), + width: 2, + height: 1, + xf: vectorcraft_geom::Affine::scale(20.0), + link: None, + placement: Default::default(), + }; + let (id, layer) = (broken.alloc_id(), broken.layers[0].id); + broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let mut s = session(400.0, 400.0, 1); + s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap(); + let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))); + let stop = AtomicBool::new(false); + let (tx, rx) = std::sync::mpsc::channel(); + // Nothing in the scope panics, so the other thread is always told to stop. + let (theirs, ours) = std::thread::scope(|scope| { + let other = scope.spawn(|| { + let mut warned = vec![]; + while !stop.load(Ordering::Relaxed) { + let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::>()); + warned.push(w.is_ok_and(|w| says_so(&w))); + let _ = tx.send(()); + } + warned + }); + let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok(); + let ours: Vec> = (0..5) + .map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w)))) + .collect(); + stop.store(true, Ordering::Relaxed); + (other.join().ok(), started.then_some(ours)) + }); + let theirs = theirs.expect("the other thread's exports"); + assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}"); + assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image"); +} + #[test] fn a_raster_export_says_when_an_image_could_not_be_decoded() { // A PNG whose header reads (so it places at its size) but whose pixel data is cut short: diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index d021a8f27..7a375462b 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -56,13 +56,17 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr /// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it. const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; -/// Images that couldn't be decoded (and so drew nothing), counted for exports to report. -static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +thread_local! { + /// Images that couldn't be decoded (and so drew nothing) on this thread, counted for exports to + /// report. + static DECODE_FAILURES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} -/// How many images have failed to decode so far (process-wide): an export compares the count -/// before and after to say it left images out. +/// How many images have failed to decode on this thread so far: an export compares the count +/// before and after it draws to say it left images out. Images that other threads (another export, +/// the canvas, thumbnails) fail to decode meanwhile are not counted. pub fn decode_failures() -> u64 { - DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed) + DECODE_FAILURES.with(std::cell::Cell::get) } /// `bytes` decoded within [`MAX_DECODE_ALLOC`] ([`decode_within_allowance`]); a failure is counted @@ -70,7 +74,7 @@ pub fn decode_failures() -> u64 { fn decode(bytes: &[u8]) -> Option { let img = decode_within_allowance(bytes).ok(); if img.is_none() { - DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + DECODE_FAILURES.with(|n| n.set(n.get() + 1)); } img } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 70b8a4559..57e0e5304 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -776,6 +776,23 @@ fn invalid_dash_pattern_strokes_solid() { } } +/// An image that can't be decoded is counted on the thread that tried to decode it: an export +/// compares [`decode_failures`] before and after it draws, so another thread's failure meanwhile is +/// not its own. +#[test] +fn decode_failures_counts_this_threads_images_only() { + let undecodable = || paint::decode_pixmap(b"\x89PNG\r\n\x1a\n").is_none(); + let before = decode_failures(); + let other = std::thread::spawn(move || { + let start = decode_failures(); + undecodable() && decode_failures() == start + 1 + }); + assert!(other.join().unwrap(), "counted on the other thread"); + assert_eq!(decode_failures(), before, "the other thread's failure"); + assert!(undecodable()); + assert_eq!(decode_failures(), before + 1, "this thread's failure"); +} + /// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen /// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as /// Anti-aliased Images off, #394). From db2bbcf405b03206d95d1c7a84dbb36bd98fc433 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 08:21:24 -0700 Subject: [PATCH 004/184] Images: sampling and recoloring check an image's RGBA size without overflowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the Eyedropper samples an image, or Edit Colors, Recolor Artwork or a color adjustment effect recolors an embedded one, `rgba_within` reads the image's size from its header and checks its size as RGBA against the allowance. The check multiplied width × height × 4 in 64 bits. A TIFF's sides go up to 2^32 - 1 pixels, so a header of 2^31 × 2^31 pixels overflowed that product: debug and test builds panicked with "attempt to multiply with overflow", and release builds wrapped it to 0 before the decoder's own 512 MB limit refused the image. The check now compares width × height with the allowance divided by 4. Every size whose RGBA bytes fit in 64 bits gets the same answer as before, and a larger one is refused. --- crates/doc/src/pixels.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 525014fc0..e63a45bea 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -16,7 +16,7 @@ pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } /// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717). fn rgba_within(bytes: &[u8], max: u64) -> Option { let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; - if u64::from(w) * u64::from(h) * 4 > max { + if u64::from(w) * u64::from(h) > max / 4 { return None; } Some(image::load_from_memory(bytes).ok()?.to_rgba8()) @@ -143,6 +143,38 @@ mod tests { assert!(rgba_within(b"not an image", u64::MAX).is_none()); } + /// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds + /// no pixel data. + fn tiff_header(w: u32, h: u32) -> Vec { + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + // Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value. + let entries: [(u16, u16, u32); 9] = + [(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)]; + out.extend((entries.len() as u16).to_le_bytes()); + for (tag, kind, value) in entries { + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(1u32.to_le_bytes()); + out.extend(value.to_le_bytes()); + } + out.extend(0u32.to_le_bytes()); + out + } + + /// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is + /// refused like any other past the allowance, and an image within the allowance still decodes. + #[test] + fn a_size_past_64_bits_as_rgba_is_not_decoded() { + let huge = tiff_header(1 << 31, 1 << 31); + assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none()); + assert!(rgba_within(&huge, u64::MAX).is_none()); + let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap(); + let mut png = Vec::new(); + gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35"); + } + #[test] fn samples_a_pixel_or_the_average_around_it() { let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1); From 0af1741e24bac24f0bf2774f8bca13b3c04670b2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:48:36 +0700 Subject: [PATCH 005/184] Crop Image: the RGBA size check can't overflow on a huge header --- crates/engine/src/cmd/menucmds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 23e49a039..464def21b 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -672,7 +672,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { // The image's size as RGBA, read from its header, is held to that allowance before anything // is decoded (#1717). let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; - if u64::from(w) * u64::from(h) * 4 > crate::cmd::fileio::MAX_RASTER_ALLOC { + if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 { return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); } let mut reader = reader()?; From 3d2e8bbe82e77d83fc55eb4be15324089fecf90a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 05:21:05 -0700 Subject: [PATCH 006/184] PDF export: Add Around is clamped when a file opens, and art too large for f64 gets no effect image document.rasterEffectsSettings accepts addAround from 0 to 1,000 pt, but a file's raster_effects.addAround opened as it was saved. With addAround 1e300, the area of a raster effect image overflowed f64, the image scale became 0 and the image got the transform [inf, NaN, NaN, inf, x, y]. PDF export left that image out, so the drop shadow was missing. A file's addAround now opens clamped to 0 to 1,000 pt. An effect image whose area or scale is not finite is not made. Export then keeps the object with its effects, and Expand Appearance expands it without an image, as when its PNG can't be encoded. Docs: docs/mcp.md (addAround). --- crates/engine/src/cmd/rasterfx.rs | 12 ++++- crates/engine/src/tests_rastersettings.rs | 55 +++++++++++++++++++++++ crates/format/src/lib.rs | 1 + docs/mcp.md | 5 ++- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index fc3170464..3bab565d0 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -170,6 +170,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 { /// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out /// of the image, which then only holds what the effects add around it (a shadow to go under the /// vector object). The document's raster effects settings add room around it and finish its pixels. +/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not +/// positive, or when its PNG can't be encoded. pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option { // What it paints (its members' effects included), and how far its fills' and strokes' raster // effects reach beyond that. @@ -177,8 +179,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N let look = out.raster_effects.clone(); let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around; let b = b.inflate(reach, reach); + // An area too large for f64 gives a zero scale and an image transform that is not finite. + let area = b.width() * b.height(); + if !area.is_finite() { + return None; + } // Whole pixels, and no larger than 64 Mpx. - let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt()); + let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()); + if !(scale.is_finite() && scale > 0.0) { + return None; + } let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale); // Anti-alias off: hard edges (the effects themselves stay smooth). let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None }; diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index 577c801cd..b0d20753b 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -2,6 +2,7 @@ //! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults. use serde_json::{Value, json}; +use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings}; use super::*; @@ -98,6 +99,60 @@ fn add_around_grows_the_effect_image() { assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72)); } +/// The transforms of the images in `doc` flattened for output. +fn image_transforms(doc: &Document) -> Vec<[f64; 6]> { + let mut xfs = vec![]; + crate::flatten_raster_effects(doc).unwrap().walk(|n| { + if let NodeKind::Image(im) = &n.kind { + xfs.push(im.xf.as_coeffs()); + } + }); + xfs +} + +#[test] +fn a_file_with_add_around_out_of_range_opens_with_it_in_range() { + let mut s = session(); + shadowed(&mut s); + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects.add_around = 1e300; + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND); + let xfs = image_transforms(&back); + assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}"); + // 1,000 pt on every side at 72 ppi. + assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000); + d.raster_effects.add_around = -5.0; + assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0); +} + +#[test] +fn art_too_large_for_f64_gets_no_effect_image() { + let mut s = session(); + let r = shadowed(&mut s); + // Settings out of range in a document made without opening a file. + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects.add_around = 1e300; + assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new()); + let mut d = (*s.doc().unwrap().doc).clone(); + let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") }; + (*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None); + assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new()); + // Expand Appearance of it by id, with nothing selected, puts no image into the document (the + // rectangle at its own size gets one). + let normal = (*s.doc().unwrap().doc).clone(); + let mut expanded_images = |d: Document| { + let st = s.doc_mut().unwrap(); + (st.doc, st.selection) = (std::sync::Arc::new(d), Default::default()); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let mut images = 0; + s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); + images + }; + assert_eq!(expanded_images(normal), 1); + assert_eq!(expanded_images(d), 0); +} + #[test] fn the_color_model_and_anti_alias_reach_the_pixels() { let mut s = session(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4811b8ca1..d37b07685 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -552,6 +552,7 @@ pub fn load_file(bytes: &[u8]) -> Result { // Untrusted: capped, named and pointing at objects the file has. doc.tidy_saved_selections(); doc.tidy_graph_designs(); + doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); diff --git a/docs/mcp.md b/docs/mcp.md index 621301a76..8ba2017d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2344,8 +2344,9 @@ written unless the whole batch succeeds puts the saves and exports last, or runs `document.rasterEffectsSettings` holds how raster effects (shadows, glows, blurs, feathers) become images when PDF export or Expand Appearance renders them: `resolution` (ppi, also New Document's `rasterEffectsPpi`), `colorModel` (the document's `rgb`/`cmyk`, `grayscale` or `bitmap`), `background` (`white` makes the images opaque), `antiAlias` -(off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (points of room around the art) -and `preserveSpotColors` (stored). They are also the defaults of `object.rasterize`, whose params override them (its +(off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (0 to 1,000 points of room +around the art; a file's value outside that range opens as the nearer end) and `preserveSpotColors` (stored). They are +also the defaults of `object.rasterize`, whose params override them (its `clippingMask` puts the image in a clip group with the art's outline). No params reports them; any of them changes them in one undo step. PDF export renders the raster effects of every kind of object this way (paths, groups, layers, type, images, symbol instances, live objects, and those in opacity masks, symbols and pattern tiles): shadows and outer glows From 8ca4cd2cbf1b4e2420fe3ca783f0cc6f725238fd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:42:09 +0000 Subject: [PATCH 007/184] Fix graph axis labels saturating beyond the i64 range Found while testing crates/engine/src/cmd/graph.rs:193 (fmt_value) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/graph.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 4e8086393..8ade093e8 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -192,7 +192,9 @@ fn drop_empty_rows(g: &mut GraphSpec) { fn fmt_value(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { - format!("{}", v.round() as i64) + // Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so + // `{:.0}` prints its exact digits. + if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") } } else { format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string() } @@ -1900,6 +1902,16 @@ mod tests { use crate::{NodeId, Session}; + #[test] + fn fmt_value_does_not_saturate_beyond_i64() { + assert_eq!(super::fmt_value(1e19), "10000000000000000000"); + assert_eq!(super::fmt_value(2e19), "20000000000000000000"); + assert_eq!(super::fmt_value(-2e19), "-20000000000000000000"); + assert_eq!(super::fmt_value(42.0), "42"); + assert_eq!(super::fmt_value(-0.0), "0"); + assert_eq!(super::fmt_value(2.5), "2.5"); + } + fn graph(s: &mut Session, ty: &str) -> NodeId { let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; NodeId( From 3688ad7a46708c15469ac593a8aeae531b6a6e4d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:39 +0200 Subject: [PATCH 008/184] Reject '+' and other non-hex characters in Color::from_hex Found while testing crates/color/src/lib.rs:184 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 742dfb749..6fc854ff0 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -181,6 +181,10 @@ impl Color { _ => {} } let h = s.strip_prefix('#').unwrap_or(s); + // `from_str_radix` accepts a leading `+`, so check the digits first. + if !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok(); match h.len() { 3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)), @@ -412,6 +416,14 @@ mod tests { assert_eq!(Color::from_hex("zz"), None); } + #[test] + fn hex_rejects_sign_characters() { + for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] { + assert_eq!(Color::from_hex(bad), None, "{bad}"); + } + assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233")); + } + #[test] fn hsb_roundtrip() { for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] { From 80685831acbb867e655e07592c34ea400d4f9ca7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:31:26 +0200 Subject: [PATCH 009/184] Escape XML ID first characters that cannot start an XML name Found while testing crates/doc/src/xml_id.rs:31 (from_object_name) / :10 (to_object_name) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/xml_id.rs | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/xml_id.rs b/crates/doc/src/xml_id.rs index 4aa35f028..eced91be1 100644 --- a/crates/doc/src/xml_id.rs +++ b/crates/doc/src/xml_id.rs @@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String { out } -/// The XML id for an object name (the inverse of [`to_object_name`]). +/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty, +/// since a bare `_` would read back as a space. pub fn from_object_name(name: &str) -> String { let mut out = String::with_capacity(name.len().saturating_mul(2)); for (i, c) in name.chars().enumerate() { @@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String { out.push('_'); } else if c == '_' { out.push_str("_x5F_"); - } else if i == 0 && c.is_ascii_digit() { + } else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') { + // A first character that cannot start an XML name is escaped, never given a bare `_` + // prefix (that would read back as a space). out.push_str(&format!("_x{:X}_", u32::from(c))); - } else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' { - out.push(c); } else { - out.push_str(&format!("_x{:X}_", u32::from(c))); + out.push(c); } } - if out.is_empty() || !starts_name(&out) { - out.insert(0, '_'); - } out } @@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool { chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':')) } -fn starts_name(id: &str) -> bool { - id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':') -} - /// `_xHHHH_` at the start of `s`: the character and the escape's length. fn escaped(s: &str) -> Option<(char, usize)> { let body = s.strip_prefix("_x")?; @@ -114,4 +108,15 @@ mod tests { assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root")); assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid("")); } + + #[test] + fn round_trip_names_that_cannot_start_an_xml_name() { + for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] { + let id = from_object_name(name); + assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}"); + } + assert_eq!(from_object_name("-abc"), "_x2D_abc"); + assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm"); + assert_eq!(from_object_name("²x"), "_xB2_x"); + } } From efdb04db8a41e1d244a9585ab0ef3589880d24a8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 08:14:21 -0700 Subject: [PATCH 010/184] Lint: action_batch.rs compiles without an unused import on wasm32 On main, cargo xtask wasm printed an unused-import warning for json in crates/engine/src/cmd/fileio/action_batch.rs, and this check reported it as an error: RUSTFLAGS=-Dwarnings cargo check -p vectorcraft-engine --target wasm32-unknown-unknown Every use of json! in action_batch.rs is in code compiled only for native targets. The wasm32 version of action_batch uses Value only. The json import now has the same #[cfg(not(target_arch = "wasm32"))] attribute as the code that uses it. Native builds import the same names as before. --- crates/engine/src/cmd/fileio/action_batch.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index ccc486896..f81957ff3 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -5,7 +5,9 @@ //! Save and Close, or Folder; Override Open / Save / Export. Cited: //! -use serde_json::{Value, json}; +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; use super::super::*; From 743930ac1188d162a86d90b96ea4933c9306cbb7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:35:33 +0200 Subject: [PATCH 011/184] Wayland: give winit the desktop cursor theme so the pointer returns over panels (#2450) Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/cursor_theme.rs | 121 ++++++++++++++++++ apps/vectorcraft/src/main.rs | 4 + .../ai.storyteller.vectorcraft.bundle.yml | 4 + .../flatpak/ai.storyteller.vectorcraft.yml | 4 + 4 files changed, 133 insertions(+) create mode 100644 apps/vectorcraft/src/cursor_theme.rs diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs new file mode 100644 index 000000000..b23d1099d --- /dev/null +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -0,0 +1,121 @@ +//! Linux Wayland: tell winit where the cursor theme is (#2450). +//! +//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor +//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps +//! showing its default one, until the canvas hides the system pointer to paint a tool glyph +//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup +//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the +//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads. +//! +//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before +//! any thread starts and before the window opens. +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::ffi::OsString; + +/// Where a Flatpak sandbox mounts the host's cursor themes (user and system). +const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"]; + +/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`). +#[derive(Debug, Default, PartialEq)] +pub struct DesktopCursor { + pub theme: Option, + pub size: Option, +} + +/// The variables to set, given the environment, the host icon folders that exist and the +/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set +/// stay as they are. +pub fn plan(var: impl Fn(&str) -> Option, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> { + let set = |name: &str| var(name).is_some_and(|v| !v.is_empty()); + if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) { + return Vec::new(); + } + let mut out = Vec::new(); + if !set("XCURSOR_PATH") && !host_icons.is_empty() { + out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":")))); + } + if !set("XCURSOR_THEME") + && let Some(theme) = desktop.theme.filter(|t| !t.is_empty()) + { + out.push(("XCURSOR_THEME", OsString::from(theme))); + } + if !set("XCURSOR_SIZE") + && let Some(size) = desktop.size.filter(|s| (1..=512).contains(s)) + { + out.push(("XCURSOR_SIZE", OsString::from(size.to_string()))); + } + out +} + +/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`. +fn gsettings_value(output: &str) -> &str { + output.trim().trim_matches('\'') +} + +#[cfg(target_os = "linux")] +fn desktop_cursor() -> DesktopCursor { + let get = |key: &str| { + let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }; + DesktopCursor { + theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()), + size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()), + } +} + +/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them +/// unset. Call first thing in `main`, while the process is single-threaded. +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +pub fn apply() { + let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect(); + for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) { + log::debug!("cursor theme: {name}={value:?}"); + // SAFETY: called at the top of `main`, before the app starts any thread. + unsafe { std::env::set_var(name, value) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v)) + } + + fn adwaita() -> DesktopCursor { + DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) } + } + + #[test] + fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() { + let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita()); + assert_eq!( + got, + vec![ + ("XCURSOR_PATH", OsString::from("/run/host/share/icons")), + ("XCURSOR_THEME", OsString::from("Adwaita")), + ("XCURSOR_SIZE", OsString::from("24")), + ] + ); + } + + #[test] + fn settings_the_user_made_and_other_sessions_stay_untouched() { + let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")]; + assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty()); + assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty()); + // A theme-less desktop and an absurd size set nothing. + let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) }; + assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty()); + } + + #[test] + fn gsettings_output_is_unquoted() { + assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita"); + assert_eq!(gsettings_value("24\n"), "24"); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 17bc010a3..c7a395a61 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -27,6 +27,7 @@ mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu; mod control_server; +mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; mod logging; @@ -654,6 +655,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli: "; fn main() -> std::process::ExitCode { + // Before any thread starts: the pointer must find a cursor theme on Wayland (#2450). + #[cfg(target_os = "linux")] + cursor_theme::apply(); // Validate roots before logging, font scans, preferences or the window: a missing value // must not clear an environment root and start the control channel without confinement. let mut control = automation_args::ControlArg::default(); diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml index b245e0b31..687445a55 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml @@ -24,6 +24,10 @@ finish-args: - --socket=fallback-x11 # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself. + # The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible + # over the window. The host's themes are mounted under /run/host (#2450). + - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Pictures is granted for opening files by # path from the command line. diff --git a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml index 865609509..cbee5b720 100644 --- a/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.vectorcraft.yml @@ -22,6 +22,10 @@ finish-args: - --socket=wayland - --socket=fallback-x11 - --device=dri + # Cursor theme: without the cursor-shape protocol (e.g. GNOME 46) winit loads the theme itself. + # The sandbox sets no XCURSOR_PATH and ships no cursor themes, so the pointer would turn invisible + # over the window. The host's themes are mounted under /run/host (#2450). + - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons # Open/Save dialogs use the file-chooser portal (rfd), and drag-and-drop uses the document # portal, so broad filesystem access isn't needed. Pictures is granted for opening files by # path from the command line. From 37dfdd5da5fd111ff9663a09ac0a21d7ad9c8b07 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:38:48 +0200 Subject: [PATCH 012/184] Cursor: keep the system pointer where no cursor theme loads on Wayland (#2450) Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/cursor_theme.rs | 86 ++++++++++++++++++++++++++++ apps/vectorcraft/src/main.rs | 1 + crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/cursors.rs | 23 ++++++++ crates/ui-egui/src/lib.rs | 4 ++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs index b23d1099d..b0a6730df 100644 --- a/apps/vectorcraft/src/cursor_theme.rs +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -7,11 +7,15 @@ //! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the //! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads. //! +//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does +//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph. +//! //! This reads the desktop's cursor theme and size and sets what the user left unset, once, before //! any thread starts and before the window opens. #![cfg_attr(not(target_os = "linux"), allow(dead_code))] use std::ffi::OsString; +use std::path::{Path, PathBuf}; /// Where a Flatpak sandbox mounts the host's cursor themes (user and system). const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"]; @@ -48,6 +52,67 @@ pub fn plan(var: impl Fn(&str) -> Option, host_icons: &[&str], desktop out } +/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`). +const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"]; + +/// The themes `index.theme` lists after `Inherits=`. +fn inherits(index: &str) -> Vec { + index + .lines() + .find_map(|l| l.trim().strip_prefix("Inherits=")) + .map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect()) + .unwrap_or_default() +} + +/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does +/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of +/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells +/// whether a file is there. +pub fn theme_usable( + var: impl Fn(&str) -> Option, + home: Option<&Path>, + read: impl Fn(&Path) -> Option, + exists: impl Fn(&Path) -> bool, +) -> bool { + let dirs: Vec = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) { + Some(v) => std::env::split_paths(&v).collect(), + None => DEFAULT_ICON_DIRS + .iter() + .filter_map(|d| match d.strip_prefix("~/") { + Some(rest) => home.map(|h| h.join(rest)), + None => Some(PathBuf::from(d)), + }) + .collect(), + }; + let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into()); + let mut todo = vec![first]; + let mut seen: Vec = Vec::new(); + while let Some(theme) = todo.pop() { + // Bounded: themes inherit in short chains, and a cycle must end. + if seen.contains(&theme) || seen.len() >= 16 { + continue; + } + for dir in &dirs { + let base = dir.join(&theme); + if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) { + return true; + } + if let Some(index) = read(&base.join("index.theme")) { + todo.extend(inherits(&index)); + } + } + seen.push(theme); + } + false +} + +/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never +/// sets a pointer, and the canvas must not hide the system one (#2450). +pub fn theme_missing() -> bool { + let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from); + !theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists()) +} + /// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`. fn gsettings_value(output: &str) -> &str { output.trim().trim_matches('\'') @@ -113,6 +178,27 @@ mod tests { assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty()); } + #[test] + fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() { + let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"]; + let exists = |p: &Path| p.to_str().is_some_and(|p| files.contains(&p)); + let read = |p: &Path| match p.to_str()? { + "/i/Breeze/index.theme" => Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()), + "/i/Loop/index.theme" => Some("Inherits=Loop\n".to_owned()), + _ => None, + }; + let with = |theme: &'static str| { + move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme))) + }; + assert!(theme_usable(with("Bibata"), None, read, exists)); + assert!(theme_usable(with("Breeze"), None, read, exists)); + // No such theme, a theme without cursors, an inheritance loop: nothing to load. + assert!(!theme_usable(with("Nope"), None, read, exists)); + assert!(!theme_usable(with("Loop"), None, read, exists)); + // Unset: Xcursor's default folders and the `default` theme. + assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists)); + } + #[test] fn gsettings_output_is_unquoted() { assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita"); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index c7a395a61..23aaea179 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -526,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services { start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), dialogs_are_sheets: cfg!(target_os = "macos"), wayland, + no_cursor_theme: wayland && cursor_theme::theme_missing(), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), // Background Save and Export write from a worker thread. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e6b6157f8..2870d1d51 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -565,7 +565,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { && crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused)) && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => { - egui::CursorIcon::None + // No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450). + if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) } } _ => cursor_icon(c), } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index bdf2d127c..4f6723118 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -581,6 +581,13 @@ pub fn os_cursors(build: bool, wayland: bool) -> bool { build && !wayland } +/// Does the canvas hide the system pointer to show a painted tool glyph alone? Not when there is no +/// cursor theme on Wayland ([`crate::Services::no_cursor_theme`]): winit sets no pointer then, and a +/// hidden one would not come back over the panels (#2450). The system pointer stays beside the glyph. +pub fn hide_system_pointer(no_cursor_theme: bool) -> bool { + !no_cursor_theme +} + /// Does the window hide the pointer only while it is the key window? macOS does: winit hides it with /// a cursor rect holding a transparent cursor, and macOS applies the rect only in the key window. /// Over a window behind another app or under a sheet, the system pointer shows (#2020). @@ -927,6 +934,22 @@ mod tests { assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None); } + /// Without a cursor theme the canvas keeps the system pointer visible (#2450). + #[test] + fn no_cursor_theme_keeps_the_system_pointer() { + assert!(!hide_system_pointer(true)); + assert!(hide_system_pointer(false)); + let mut app = VectorcraftApp::new(Session::new(), crate::Services { wayland: true, no_cursor_theme: true, ..Default::default() }); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let over = app.canvas_rect.unwrap().center(); + let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); + assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::None); + } + /// On macOS a window without the focus shows the system pointer over the canvas, and the glyph /// is not painted next to it (#2020). With the focus back, the canvas sets `CursorIcon::None` again /// on the next frame, without a pointer move. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bc1fe3d8a..64998ed24 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -275,6 +275,10 @@ pub struct Services { /// The window is on Wayland: the tool cursors are painted into it instead of handed to the /// compositor as bitmaps ([`cursors::os_cursors`]). pub wayland: bool, + /// On Wayland, no cursor theme can be loaded, so winit sets no pointer and the compositor's + /// default one would never come back once the canvas hid it: the canvas keeps the system + /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). + pub no_cursor_theme: bool, } /// Cached canvas raster. From 6961bed99debd5a3fe69c4f9f3c05dd555c4bd5f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:51:46 -0700 Subject: [PATCH 013/184] Tests: CLI tests do not fail on folders an earlier run left behind The tmp() helpers in apps/vectorcraft-cli/tests/cli.rs and fileio.rs name their folder after the process id (vectorcraft-cli-test-, vectorcraft-cli-fileio- in the temp folder) and never empty or remove it. When a later run gets the same process id, it finds the earlier run's files there. mcp_automation_roots then fails on Unix with EEXIST (Os { code: 17, kind: AlreadyExists, message: "File exists" }): its first symlink() call finds the link in/escape.svg that the earlier run made. A stale output file can also let a fileio test read an earlier run's export. The first tmp() call in each test process now removes the folder and creates it again empty. The roots test's body moves into automation_roots(), and mcp_automation_roots_in_a_folder_an_earlier_run_left runs it twice in one folder, emptying the folder in between as tmp() does. Without the removal the second run fails with the same error. --- apps/vectorcraft-cli/tests/cli.rs | 27 ++++++++++++++++++++++++--- apps/vectorcraft-cli/tests/fileio.rs | 11 +++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 37bb28c22..133c51df1 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -7,10 +7,18 @@ use serde_json::{Value, json}; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); +/// Returns the path of `name` in `vectorcraft-cli-test-` in the temp folder. The first call removes +/// that folder and creates it again empty. fn tmp(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())); + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name) +} + +/// Removes `dir` and everything in it, creates it again empty and returns it. +fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf { + let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir.join(name) + dir } #[test] @@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() { /// server starts, and the process then reads and writes only inside its roots. #[test] fn mcp_automation_roots() { - let base = tmp("roots"); + automation_roots(&tmp("roots")); +} + +/// A run that has the process id of an earlier run finds that run's files and links in its folder. +/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing +/// with "File exists". +#[test] +fn mcp_automation_roots_in_a_folder_an_earlier_run_left() { + let dir = tmp("earlier-run"); + automation_roots(&dir.join("roots")); + automation_roots(&empty_dir(dir).join("roots")); +} + +fn automation_roots(base: &std::path::Path) { for d in ["in", "out", "outside"] { std::fs::create_dir_all(base.join(d)).unwrap(); } diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/fileio.rs index db1174188..19e2b7b72 100644 --- a/apps/vectorcraft-cli/tests/fileio.rs +++ b/apps/vectorcraft-cli/tests/fileio.rs @@ -8,9 +8,16 @@ use serde_json::Value; const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli"); +/// Returns the path of `name` in `vectorcraft-cli-fileio-` in the temp folder. The first call +/// removes that folder and creates it again empty. fn tmp(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + let dir = DIR.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + }); dir.join(name).to_string_lossy().to_string() } From 476d22826228ce419103fe03b04764129f284cbd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 16:09:12 -0700 Subject: [PATCH 014/184] Tests: a language one ui-egui test sets no longer translates the tests running beside it The language the UI is drawn in was one process-wide value in unit tests too, and the tests run on parallel threads. current_language_is_a_frame_setting sets Traditional Chinese for a moment, and a test that drew text in that moment read it in Chinese. chrome::tests::the_zoom_hint_follows_animated_zoom failed that way in 29 of 500 runs together with the i18n tests. The drawing language is now one per process in the app and one per thread in unit tests, as ui_mirror! keeps the shortcut and workspace mirrors. The app keeps the same static. A new test sets the language on another thread and checks that its own thread still draws in English. --- crates/ui-egui/src/i18n/mod.rs | 22 ++++++++++++++++++---- crates/ui-egui/src/i18n/tests.rs | 12 ++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index e2d8139d6..9698fb82a 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -457,19 +457,33 @@ fn detect_system_lang() -> Lang { Lang::EN } -/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame. -static CURRENT: AtomicUsize = AtomicUsize::new(0); +/// Index into [`LANGUAGES`] of the language the UI is drawn in this frame. The app keeps one +/// index for the whole process. Unit tests keep one per thread, as `ui_mirror!` keeps the +/// shortcut and workspace mirrors, so a language one test sets does not change the language of +/// the tests running beside it on other threads. +fn current_index() -> &'static AtomicUsize { + #[cfg(not(test))] + { + static CURRENT: AtomicUsize = AtomicUsize::new(0); + &CURRENT + } + #[cfg(test)] + { + thread_local!(static CURRENT: &'static AtomicUsize = Box::leak(Box::default())); + CURRENT.with(|c| *c) + } +} /// Set the UI language for drawing (the shell calls this once per frame from the preference), so /// widgets can translate without every call site carrying a language around. pub fn set_current(lang: Lang) { let i = LANGUAGES.iter().position(|l| l.code == lang.code()).unwrap_or(0); - CURRENT.store(i, Ordering::Relaxed); + current_index().store(i, Ordering::Relaxed); } /// The language the UI is drawn in. pub fn current() -> Lang { - Lang(LANGUAGES.get(CURRENT.load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0])) + Lang(LANGUAGES.get(current_index().load(Ordering::Relaxed)).unwrap_or(&LANGUAGES[0])) } /// Does `lang` have a catalog entry for this plain string? (English never does: it is the source.) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f5afab545..3ab33db42 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -100,6 +100,15 @@ fn current_language_is_a_frame_setting() { assert_eq!(t("Layers"), "Layers"); } +/// Unit tests run on parallel threads, and each one draws in its own language: a language set on +/// another thread (by a test running beside this one) leaves this thread's text in English. +#[test] +fn a_language_set_by_another_test_leaves_this_one_in_english() { + std::thread::spawn(|| set_current(ZH())).join().unwrap(); + assert_eq!(current(), Lang::EN); + assert_eq!(t("Layers"), "Layers"); +} + #[test] fn lookups_fall_back_to_english() { assert_eq!(tr(Lang::EN, "Layers"), "Layers"); @@ -197,8 +206,7 @@ fn partial_catalogs_only_translate_strings_the_ui_shows() { } /// The engine learns the language the UI is drawn in each frame: new type takes the Japanese -/// defaults while it is Japanese (#432). The frames stay in English: the drawing language is -/// process-wide, and a Japanese frame would translate the tests running beside this one. +/// defaults while it is Japanese (#432). The frames stay in English. #[test] fn the_engine_follows_the_interface_language() { let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); From 1ff7ac75574efe6fa6b0b1064d7ee97b80329351 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:27:30 +0200 Subject: [PATCH 015/184] Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layerpanel.rs | 15 ++++++++++++-- crates/engine/src/tests_layers.rs | 32 ++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index f82e2890a..c302a7409 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result { if ids.len() < 2 { return Err(bad(C, "highlight two or more rows to merge")); } - let into = - ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?; + // The last highlighted layer that is not inside another highlighted row: with a layer and its + // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer + // instead of becoming the target (#2383). + let roots: HashSet = row_roots(d, &ids).into_iter().collect(); + let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer); + let into = ids + .iter() + .rev() + .copied() + .filter(is_layer) + .find(|id| roots.contains(id)) + .or_else(|| ids.iter().rev().copied().find(is_layer)) + .ok_or_else(|| bad(C, "highlight a layer to merge into"))?; let around = d.ancestry(into).unwrap_or_default(); // (Rows inside the target layer merge too: only rows inside other listed rows go with those.) let rest: Vec = ids.iter().copied().filter(|id| *id != into).collect(); diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 1e2e7e221..1fc789c80 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -578,7 +578,11 @@ fn collect_merge_flatten_and_reverse() { run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]})); assert!(s.doc().unwrap().doc.node(col).is_none()); assert_eq!(children(&s, layer), vec![c, sub, a, g]); - assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer"); + // A layer and its sublayer: the sublayer merges into its layer, never the other way round. + run(&mut s, "layer.merge", json!({"ids": [layer.0, sub.0]})); + assert!(s.doc().unwrap().doc.node(sub).is_none()); + assert_eq!(children(&s, layer), vec![c, a, g]); + run(&mut s, "edit.undo", json!({})); assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err()); // Flatten: other visible layers merge in, hidden ones go, templates stay. let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); @@ -595,6 +599,32 @@ fn collect_merge_flatten_and_reverse() { assert_eq!(undos(&s), before + 1); } +/// Merge Selected with every row highlighted (a layer, its sublayer and another layer, in panel +/// order) merges the layers into the last top-level one instead of refusing a sublayer target (#2383). +#[test] +fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level_layer() { + for reversed in [false, true] { + let mut s = session(); + let (layer, sub, a, _, _, c) = tree(&mut s); + let l2 = id(&run(&mut s, "layer.new", json!({"top": true}))); + let d = rect(&mut s, 500.0); + // Panel order, top first: Layer 2, Layer 1, its sublayer. + let mut rows = vec![l2.0, layer.0, sub.0]; + if reversed { + rows.reverse(); + } + run(&mut s, "layer.highlight", json!({"ids": rows})); + let into = id(&run(&mut s, "layer.merge", json!({}))); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.len(), 1, "reversed: {reversed}"); + assert_eq!(into, st.doc.layers[0].id); + for n in [a, c, d] { + assert!(st.doc.layer_of(n) == Some(into), "{n:?} reversed: {reversed}"); + } + assert!(st.doc.node(sub).is_some() || st.doc.node(layer).is_some() || st.doc.node(l2).is_some()); + } +} + /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key From 7e423f91e934eb63873f5f0d2e1a3c9b7d95df15 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:08:36 +0200 Subject: [PATCH 016/184] Overprint Preview: overprinting draws with Darken, so a colour over itself stays that colour (#2415) Multiply squared a colour overprinting itself (stroke over same-colour fill showed a darker band). Darken keeps the stronger ink per plate/channel. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/overprint.rs | 17 +++++++++-------- crates/pdf/src/print/mod.rs | 8 ++++---- crates/pdf/src/print/plates.rs | 6 +++--- crates/pdf/src/tests_printadvanced.rs | 4 ++-- crates/render/src/proof.rs | 8 ++++---- crates/render/src/tests_cmykblend.rs | 19 +++++++++++++++++++ docs/mcp.md | 4 ++-- 7 files changed, 43 insertions(+), 23 deletions(-) diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index 50b54d3bc..a822728a3 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -94,26 +94,27 @@ impl Node { } } -/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend +/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend /// mode of their own keep it), copying only the nodes on the way to them: on screen and on a -/// printed plate this approximates their inks printing over the inks below. With `discard_white` +/// printed plate this approximates their inks printing over the inks below. Darken keeps the +/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays +/// that colour, where Multiply would darken it. With `discard_white` /// (Document Setup → Discard White Overprint) white fills and strokes knock out instead. -pub fn multiply_overprints(a: &mut Arc, discard_white: bool) { +pub fn darken_overprints(a: &mut Arc, discard_white: bool) { if !a.has_overprint() { return; } let n = Arc::make_mut(a); - let multiplies = - |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint)); + let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint)); for it in &mut n.appearance.items { match it { - crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, - crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply, + crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken, + crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken, _ => {} } } for c in n.children_mut().into_iter().flatten() { - multiply_overprints(c, discard_white); + darken_overprints(c, discard_white); } } diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 440231032..34762a2e0 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -36,7 +36,7 @@ use kurbo::{Affine, Rect}; use serde::Serialize; use vectorcraft_color::cms::{self, ProfileKind}; use vectorcraft_doc::marks::PrinterMarks; -use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints}; +use vectorcraft_doc::overprint::{clear_overprints, darken_overprints}; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; pub use layout::{MAX_TILES, TileGrid}; @@ -167,7 +167,7 @@ pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, D } /// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated -/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any +/// (drawn with Darken, as Overprint Preview shows them), after which nothing overprints any /// more; or preserved. Separations always honour them. fn composite_overprints(doc: &mut Document, set: &PrintSettings) { let how = set.advanced.overprints; @@ -179,7 +179,7 @@ fn composite_overprints(doc: &mut Document, set: &PrintSettings) { let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())); for n in nodes { if how == PrintOverprints::Simulate { - multiply_overprints(n, discard_white); + darken_overprints(n, discard_white); } clear_overprints(n); } @@ -411,7 +411,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions) -> Result Separator<'a> { } /// The document as plate `plate`: art, symbols, pattern tiles and images in the grey of the - /// ink's coverage. Overprinting fills and strokes multiply, so where they have none of this + /// ink's coverage. Overprinting fills and strokes darken, so where they have none of this /// ink the plate below shows through instead of being knocked out. pub fn plate(&self, plate: &str) -> Document { let mut d = self.doc.clone(); let discard_white = d.setup.discard_white_overprint; let nodes = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.patterns.iter_mut().flat_map(|p| p.art.iter_mut())); for n in nodes { - multiply_overprints(n, discard_white); + darken_overprints(n, discard_white); self.node(plate, Arc::make_mut(n)); } for blob in d.images.values_mut() { diff --git a/crates/pdf/src/tests_printadvanced.rs b/crates/pdf/src/tests_printadvanced.rs index ca0abc07c..d1ae7c2e3 100644 --- a/crates/pdf/src/tests_printadvanced.rs +++ b/crates/pdf/src/tests_printadvanced.rs @@ -98,13 +98,13 @@ fn discarded_and_simulated_overprints_write_no_overprint() { assert!(preserve.warnings.is_empty(), "{:?}", preserve.warnings); assert!(String::from_utf8_lossy(&preserve.bytes).contains("/OP true/op true/OPM 1"), "preserved overprints overprint in the file"); assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved"); - for (how, multiplies) in [("discard", false), ("simulate", true)] { + for (how, darkens) in [("discard", false), ("simulate", true)] { let set = json!({"advanced": {"overprints": how}}); assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints"); let r = job(&d, set); let text = String::from_utf8_lossy(&r.bytes); assert!(!text.contains("/OP"), "{how}: no overprint in the file"); - assert_eq!(text.contains("/Multiply"), multiplies, "{how}"); + assert_eq!(text.contains("/Darken"), darkens, "{how}"); assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings); } // Separations honour overprints whatever the option says. diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 10db44cb6..86c0319ed 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -11,7 +11,7 @@ //! Placed raster images are not separated (limitation). //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that //! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with -//! Multiply, which approximates their inks printing over the inks below: a zero ink lets the +//! Darken, which approximates their inks printing over the inks below: a zero ink lets the //! inks below show through, where a knockout would replace them. //! //! The app's current view state lives in [`view`] / [`set_view`]; the canvas copies it into @@ -25,7 +25,7 @@ use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; use vectorcraft_color::{Color, Swatch, SwatchGroup}; pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color}; -use vectorcraft_doc::overprint::multiply_overprints; +use vectorcraft_doc::overprint::darken_overprints; use vectorcraft_doc::{Document, Node, NodeId, Shared}; use crate::RenderOptions; @@ -104,7 +104,7 @@ impl PartialEq for Plates { } /// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply -/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates +/// ([`darken_overprints`]), and with Separations Preview every color shows as the visible plates /// print it ([`plate_color`]). struct How<'a> { doc: &'a Document, @@ -122,7 +122,7 @@ impl How<'_> { /// Change the fills, strokes and other colors of node `a`, which holds no members. fn apply(&self, a: &mut Arc) { - multiply_overprints(a, self.discard_white); + darken_overprints(a, self.discard_white); if let Some((c, proof, visible)) = self.plates { map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw)); } diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 3ab3b19e8..9d3f1d933 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -261,3 +261,22 @@ fn rich_black_shows_black_ink_alone_as_rgb_black() { add(&mut d, n); assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is"); } + +/// An overprinting stroke in the colour of the fill under it shows no darker band in Overprint +/// Preview: the same ink printed over itself is that ink (#2415). Multiply would square the colour. +#[test] +fn overprint_of_the_same_colour_shows_no_darker_band() { + for (mode, c) in [(ColorMode::Rgb, Color::rgb(0.0, 0.55, 0.29)), (ColorMode::Cmyk, Color::cmyk(0.85, 0.0, 1.0, 0.0))] { + let mut d = Document::new_with_mode(40.0, 40.0, mode); + let mut n = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::basic(Paint::solid(c), Paint::solid(c), 8.0)); + for it in &mut n.appearance.items { + *it.overprint_mut() = true; + } + add(&mut d, n); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), overprint_preview: true, ..Default::default() }; + let r = Renderer::new().render_region_with(&d, d.artboards[0].rect, 1.0, &opts); + // (20, 20) is fill only; (12, 20) lies in the stroke's inner half, over the fill. + assert!(near(r.pixel(12, 20), r.pixel(20, 20), 2), "{mode:?}: {:?} vs {:?}", r.pixel(12, 20), r.pixel(20, 20)); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 8ba2017d5..bed84a08a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1107,7 +1107,7 @@ Each fill and stroke (and each character's fill and stroke) can overprint: its i of knocking them out. `object.setOverprint {fill?, stroke?, item?, ids?}` sets it (groups set their contents, type its characters too; `item` aims at one appearance item) and `attributes.info {ids?}` reads it back (`null` where the objects differ). Overprint Preview (`view.overprintPreview`) and Separations Preview show it; rendering approximates -it by multiplying. Older files that listed Overprint Black objects in the document get the flags on load. +it by darkening (the stronger ink of each plate wins, so a colour overprinting itself stays that colour). Older files that listed Overprint Black objects in the document get the flags on load. PDF export writes them with `advanced.overprint: "preserve"` (the default): each overprinting fill and stroke sets a graphics state with `/OP true /op true /OPM 1` (`/OPM 0` in PDF/A). With `discardWhiteOverprint` (`document.setup`, on by default) white ones knock out instead; `"discard"` writes none. @@ -3049,7 +3049,7 @@ The Print dialog's Advanced section and the printer profile are print settings t `file.print`): `advanced: {printAsBitmap, overprints: preserve|discard|simulate, flattenerPreset}` and `color: {profile}`. In composite output, `preserve` writes overprinting fills and strokes with an overprinting graphics state (`/OP`, as PDF export does), `discard` makes overprinting fills and strokes knock out and `simulate` prints them -as Overprint Preview shows them (multiplied); either way the file has no overprint left. Separations always keep +as Overprint Preview shows them (darkened); either way the file has no overprint left. Separations always keep overprints. `flattenerPreset` (High Resolution, Medium Resolution, Low Resolution or a saved preset, `flattener.presets.list`) flattens transparency before printing, as Object → Flatten Transparency does; empty, a PDF keeps it live (PostScript, which has none, uses `file.print`'s `flattenerPreset`, else this one, else medium). From fdc08e25d6aa2c5341c56a26b6b432765a359d6b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:33:08 +0200 Subject: [PATCH 017/184] Layers: Merge Selected merges the layers of art selected on the canvas (#2383) With no highlighted rows and art selected in two or more layers (Select All), layer.merge merges those layers; the panel menu enables it then. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layerpanel.rs | 13 +++++++++++-- crates/engine/src/tests_layers.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 4 +++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index c302a7409..66cf37ff7 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,13 +263,22 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { fn merge(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.merge"; - let ids = rows_param(s, p, C)?; + let mut ids = rows_param(s, p, C)?; let st = s.doc()?; let d = &st.doc; + // Nothing named or highlighted, but art selected on the canvas in several layers (Select All): + // merge those layers (#2383). + if p.get("ids").is_none() && p.get("id").is_none() && st.highlighted_rows().is_empty() { + let held: HashSet = st.selection.objects.iter().filter_map(|o| d.layer_of(*o)).collect(); + let layers: Vec = d.layers.iter().map(|l| l.id).filter(|l| held.contains(l)).collect(); + if layers.len() >= 2 { + ids = layers; + } + } let mut seen = HashSet::new(); let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect(); if ids.len() < 2 { - return Err(bad(C, "highlight two or more rows to merge")); + return Err(bad(C, "highlight two or more rows to merge, or select art in two or more layers")); } // The last highlighted layer that is not inside another highlighted row: with a layer and its // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 1fc789c80..7591873d2 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -625,6 +625,24 @@ fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level } } +/// Merge Selected with no highlighted rows but art selected in several layers (Select All on the +/// canvas) merges those layers; art in one layer still asks for rows (#2383). +#[test] +fn merge_selected_with_art_selected_in_several_layers_merges_those_layers() { + let mut s = session(); + let a = rect(&mut s, 0.0); + run(&mut s, "layer.new", json!({"top": true})); + let b = rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [b.0]})); + run(&mut s, "layer.highlight", json!({"ids": []})); + assert!(s.execute("layer.merge", &json!({})).is_err()); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let into = id(&run(&mut s, "layer.merge", json!({}))); + let st = s.doc().unwrap(); + assert_eq!(st.doc.layers.len(), 1); + assert!(st.doc.layer_of(a) == Some(into) && st.doc.layer_of(b) == Some(into)); +} + /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 7fe651ced..0642274ea 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1461,6 +1461,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let rows = st.highlighted_rows(); let current = st.current_layer(); let has_sel = !st.selection.is_empty(); + // How many top-level layers hold the selected art: Merge Selected merges them. + let art_layers = st.selection.objects.iter().filter_map(|o| doc.layer_of(*o)).collect::>().len(); let isolated = st.isolation.is_some(); let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; let one_container = match targets.as_slice() { @@ -1498,7 +1500,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { None, Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), None, - Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2 || (rows.is_empty() && art_layers >= 2), false)), Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). From 1fbd35560d0c57441ca3ad4207704ff54f5cd6c5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:06:47 +0700 Subject: [PATCH 018/184] Tests: the cursor-theme test compares paths, so it passes on Windows (#2450) --- apps/vectorcraft/src/cursor_theme.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/vectorcraft/src/cursor_theme.rs b/apps/vectorcraft/src/cursor_theme.rs index b0a6730df..ebb8ce871 100644 --- a/apps/vectorcraft/src/cursor_theme.rs +++ b/apps/vectorcraft/src/cursor_theme.rs @@ -181,11 +181,16 @@ mod tests { #[test] fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() { let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"]; - let exists = |p: &Path| p.to_str().is_some_and(|p| files.contains(&p)); - let read = |p: &Path| match p.to_str()? { - "/i/Breeze/index.theme" => Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()), - "/i/Loop/index.theme" => Some("Inherits=Loop\n".to_owned()), - _ => None, + // Compared as paths, so the joins match on Windows too, where they use `\`. + let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p); + let read = |p: &Path| { + if p == Path::new("/i/Breeze/index.theme") { + Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned()) + } else if p == Path::new("/i/Loop/index.theme") { + Some("Inherits=Loop\n".to_owned()) + } else { + None + } }; let with = |theme: &'static str| { move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme))) From 57c79dc0b4d0b0330907a20c8f50a1d157ff96bf Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:17:17 +0200 Subject: [PATCH 019/184] PNG export: an sRGB chunk marks the colours as sRGB, embedIcc turns it off (#2221) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 3 +++ crates/engine/src/cmd/fileio/mod.rs | 6 +++--- crates/engine/src/cmd/fileio/tests_raster.rs | 15 +++++++++++++++ docs/file-format-parity.md | 2 +- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 09f6a4d08..7c26de22a 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -479,6 +479,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { } else { bytes }; + // PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false. + let bytes = + if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes }; enc.files.push((Some(b), bytes)); } if enc.files.is_empty() { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 93282d33d..b85fa7306 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -348,7 +348,7 @@ const EMBED_ICC: FormatOption = FormatOption { name: "embedIcc", ty: "boolean", default: "true", - description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve", + description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)", }; const IMAGE_MAP: FormatOption = FormatOption { name: "imageMap", @@ -560,7 +560,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC], }, Format { id: "jpg", diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 110847ed5..fbcb9ad58 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone() assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks"); } +#[test] +fn png_says_it_is_srgb_unless_embed_icc_is_false() { + let mut s = session(40.0, 30.0, 1); + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap()); + let on = png(&mut s, json!({"format": "png"})); + assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent"); + let off = png(&mut s, json!({"format": "png", "embedIcc": false})); + assert!(png_chunk(&off, b"sRGB").is_none()); + assert_eq!(rgba(&on), rgba(&off), "same pixels either way"); + let r = s.execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); + assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc"); +} + #[test] fn formats_list_the_raster_options() { let mut s = Session::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b848ff79b..d0010471b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -91,7 +91,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (merged image) | no | | -| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584) | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | From 5fd7fd43121c7e7a05991fe7718d7f0994a67b98 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:15:20 +0200 Subject: [PATCH 020/184] Character: the Kerning field at a caret kerns the pair, not the whole text (#2217) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/character.rs | 48 +++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 21d0ce6fb..a36edc790 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -147,6 +147,28 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { range_style(app, p); } +/// Set the Kerning field's `value` (a number or "auto"). At a caret in text the Type tool edits +/// it is the pair kerning after the character before the caret, as Alt+←/→ steps it +/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like +/// the others: the selected range, or all the text. +fn set_kerning(app: &mut VectorcraftApp, value: Value) { + let Some((id, a, b)) = text_editing(app) else { + format(app, json!({"kerning": value})); + return; + }; + if b > a { + range_style(app, json!({"kerning": value})); + return; + } + let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return }; + let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a); + if start >= a { + return; + } + end_typing(app); + app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok(); +} + /// The faces Type › Bold and Type › Italic switch to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Face { @@ -450,7 +472,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: & cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| { let kerning = shared(app, |s| s.kerning.unwrap_or(0.0)); if let Some(v) = widgets::spin_plain(ui, "ch-kern", kerning, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { - format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) }); + set_kerning(app, if v == 0.0 { json!("auto") } else { json!(v) }); } }); cell(ui, "VA↔", tl!("Tracking"), |ui| { @@ -783,6 +805,30 @@ mod tests { assert_eq!(text_style(&app).unwrap().0.size, 5.0); } + /// The Kerning field at a caret kerns that pair, not the whole text (#2217). + #[test] + fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "AVAX", "size": 12})).unwrap()["id"].clone(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + set_kerning(&mut app, json!(-50)); + let kerning = |app: &VectorcraftApp, at: usize| { + let st = app.session.active().unwrap(); + let Some(NodeKind::Text(t)) = st.doc.node(NodeId(id.as_u64().unwrap())).map(|n| &n.kind) else { panic!("text") }; + vectorcraft_text::edit::style_at(&t.runs, at + 1).kerning // the character at `at` + }; + assert_eq!(kerning(&app, 1), Some(-50.0), "the character before the caret"); + assert_eq!(kerning(&app, 3), None, "the rest is untouched"); + assert_eq!(kerning(&app, 0), None); + // A selected range still takes it whole. + app.session.set_tool_option("select", &json!({"start": 2, "end": 4})); + set_kerning(&mut app, json!(30)); + assert_eq!((kerning(&app, 1), kerning(&app, 2), kerning(&app, 3)), (Some(-50.0), Some(30.0), Some(30.0))); + } + /// Bold and Italic pick the family's own faces by name, and toggle back (#724). #[test] fn bold_and_italic_pick_the_familys_own_faces() { From d39e02b3fe6b4ec744eab0803c0b8f7882888f2b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:14:46 +0200 Subject: [PATCH 021/184] Type: selected overflowing type shows its red out port with any tool (#2323) The overflow marker was drawn by the Type tools only, so with the Selection tool a text box smaller than its text showed no sign that text was hidden. Session::overlays now adds the marker of every selected area type and type on a path that overflows, unless a Type tool is active (it draws its own). The verdict is cached per object and font generation. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 3 ++ crates/engine/src/tests_textedit.rs | 20 +++++++++ crates/engine/src/tooling.rs | 6 +++ crates/tools/src/text.rs | 65 ++++++++++++++++++++++------- crates/tools/src/text_tests.rs | 18 ++++++++ 5 files changed, 96 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a16fc502e..395a83910 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1198,6 +1198,8 @@ pub struct Session { pub(crate) plane_widget_press: bool, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option, + /// Which selected type overflows its frame, for the markers every tool but the Type tools shows. + pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks, /// The last search for the files of missing fonts (`text.findFontFiles`), until another /// starts; dropping it stops it. pub(crate) font_search: Option, @@ -1268,6 +1270,7 @@ impl Session { liquify_stroke: None, plane_widget_press: false, ruler_guide: None, + overflow_marks: Default::default(), font_search: None, link_search: None, search_rules: None, diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 28ac51c9a..26713fa92 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -737,3 +737,23 @@ fn a_colour_previewed_while_typing_keeps_the_typing() { s.tool_text("d", v).unwrap(); assert_eq!(obj(&s, id).plain_text(), "abcd"); } + +/// The red out port of selected overflowing area type is drawn with the Selection tool too, and +/// not twice with the Type tool, which draws it itself (#2323). +#[test] +fn overflow_marker_shows_with_the_selection_tool() { + let mut s = session(); + let v = ViewInfo::default(); + let long = "Words that fill more than the frame holds. ".repeat(20); + let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", v).unwrap(); + let red = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [230, 30, 30], .. })).count(); + assert_eq!(red(&mut s), 1, "the out port"); + s.select_tool("type", v).unwrap(); + assert_eq!(red(&mut s), 1, "the Type tool's own, not a second one"); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.select_tool("selection", v).unwrap(); + assert_eq!(red(&mut s), 0, "nothing selected"); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9fd526e9e..b8609689f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -399,6 +399,12 @@ impl Session { pub fn overlays(&mut self, view: ViewInfo) -> Vec { let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); + // The Type tools draw the overflow markers of selected type themselves. + if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") { + let mut marks = std::mem::take(&mut self.overflow_marks); + v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx))); + self.overflow_marks = marks; + } if let Some(g) = self.ruler_guide.take() { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); self.ruler_guide = Some(g); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index a48a4b366..56ada31f4 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -305,26 +305,59 @@ impl TypeTool { } fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec) { - if !lay.overflow { - return; + if lay.overflow { + overflow_port(t, cx, o); } - // The out port: bottom-right of the frame (end of the path for type on a path). - let port = match &t.kind { - TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)), - TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)), - TextKind::Point => None, - }; - let Some(c) = port else { return }; - let h = cx.tol(4.5); - let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); - let red = [230, 30, 30]; - o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false }); - let (k, m) = (cx.tol(2.5), r.center()); - o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false }); - o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false }); } } +/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit): at the +/// bottom right of the frame, at the end of the path for type on a path. +fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec) { + let port = match &t.kind { + TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)), + TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)), + TextKind::Point => None, + }; + let Some(c) = port else { return }; + let h = cx.tol(4.5); + let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); + let red = [230, 30, 30]; + o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false }); + let (k, m) = (cx.tol(2.5), r.center()); + o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false }); + o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false }); +} + +/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept +/// with the object and the fonts it was made from, as overlays run every frame. +#[derive(Default)] +pub struct OverflowMarks { + seen: std::collections::HashMap, +} + +/// The overflow markers of the selected area type and type on a path, for every tool but the Type +/// tools (they draw their own): an out port with a red "+" tells that text is hidden by the size of +/// its frame, whatever tool is active. +pub fn overflow_markers(marks: &mut OverflowMarks, cx: &ToolContext) -> Vec { + let fonts = FontDb::global().generation(); + let mut o = Vec::new(); + let mut kept = std::collections::HashMap::new(); + for id in &cx.selection.objects { + let Some(t) = TypeTool::text(cx, *id).filter(|t| !matches!(t.kind, TextKind::Point)) else { continue }; + let over = match marks.seen.remove(id) { + Some(((g, was), over)) if g == fonts && &was == t => over, + _ => vectorcraft_text::layout(FontDb::global(), t).overflow, + }; + if over { + overflow_port(t, cx, &mut o); + } + kept.insert(*id, ((fonts, t.clone()), over)); + } + marks.seen = kept; + o +} + /// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). /// `None` when `r` runs past the end of `s` or backwards. /// The `type.step` an Alt+arrow asks for, by one step: ←/→ kerning at a `caret`, else tracking; diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index c3005d063..7910d2506 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -400,3 +400,21 @@ fn escape_returns_to_previous_tool_when_set() { let acts2 = key(&mut tool2, &d2, ToolKey::Escape, Mods::default()); assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into()))); } + +/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not +/// only the Type tools (#2323); text that fits shows none. +#[test] +fn overflowing_selected_type_shows_its_marker_without_the_type_tool() { + let (mut d, id) = crate::testutil::doc_with_area_type(); + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + let mut marks = OverflowMarks::default(); + assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "the text fits"); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.runs = vec![TextRun::new("Words that fill more than the frame holds. ".repeat(20), CharStyle { size: 20.0, ..Default::default() })]; + } + let ov = overflow_markers(&mut marks, &cx(&d, &sel, &p)); + assert_eq!(ov.len(), 3, "a box and a plus: {ov:?}"); + sel.clear(); + assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows it"); +} From 8ac4c9d268a967c28181222f6c8acc02238d8ab5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:38:23 +0200 Subject: [PATCH 022/184] Color: Generic CMYK compensates black both ways so black round-trips (#2493) sRGB to Generic CMYK applies black-point compensation (black separates to the darkest rich black) but CMYK to display did not undo it, so hex 000000 became C67 M67 Y67 K100 and then showed as #1b1818. The display XYZ now maps the press black to black when the colour settings use black-point compensation, in the sRGB conversion, the ink lookup table the canvas uses, Lab of a CMYK colour and the gamut warning. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/cms/generic.rs | 31 +++++++++++++++++++++++------- crates/color/src/cms/mod.rs | 23 +++++++++++++++------- crates/color/src/cms/tests.rs | 14 ++++++++++++++ crates/color/src/cms/tests_icc.rs | 3 ++- crates/pdf/src/tests_cmykimages.rs | 2 +- crates/render/src/ink.rs | 2 +- crates/render/src/tests_memory.rs | 3 ++- docs/target-app-parity.md | 2 +- 8 files changed, 61 insertions(+), 19 deletions(-) diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index fc1922e13..cea213cd6 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -171,9 +171,25 @@ impl GenericCmyk { xyz_to_lab(self.to_xyz(cmyk)) } - /// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour). - pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] { - xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) }) + /// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour). + /// With `bpc` (media-relative only) the press black displays as black, undoing the + /// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the + /// darkest rich black shows as black again. + pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { + if absolute { + return self.to_xyz(cmyk); + } + let x = self.to_xyz_rel(cmyk); + if !bpc { + return x; + } + let bp = self.black_rel; + [0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0)) + } + + /// Display sRGB, see [`Self::display_xyz`]. + pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { + xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc)) } /// Lab of the darkest printable black (media-relative). @@ -425,11 +441,12 @@ impl GenericCmyk { self.from_lab(srgb_to_lab(rgb), intent, bpc) } - /// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction. - pub fn gamut_error(&self, src: Lab) -> f32 { - let target = self.gamut_map(src, Intent::RelativeColorimetric, false); + /// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays + /// (with `bpc`, the press black showing as black). + pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 { + let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc); let cmyk = self.separate(target); - delta_e2000(self.to_lab_rel(cmyk), target) + delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src) } } diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 40c13bd56..9eeafdb2c 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -346,10 +346,10 @@ impl Cms { } } - fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] { + fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] { let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0)); match space { - CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute), + CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc), CmykSpace::Device => naive_cmyk_to_rgb(cmyk), CmykSpace::Icc(p) => { let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric }; @@ -376,7 +376,7 @@ impl Cms { { return v; } - Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute) + Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc) } /// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts /// directly into the RGB profile: not through display sRGB, which would clip colours a wider @@ -408,6 +408,15 @@ impl Cms { _ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)), } } + /// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in + /// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows + /// as black). Unclipped, for interpolating tables. + pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab { + match &self.cmyk { + CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)), + _ => self.cmyk_to_lab(cmyk), + } + } /// Lab → working CMYK with `intent`. pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] { match &self.cmyk { @@ -429,7 +438,7 @@ impl Cms { /// CIE Lab (D50) of any colour. pub fn lab(&self, c: &Color) -> Lab { match *c { - Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]), + Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]), Color::Lab { l, a, b } => Lab::new(l, a, b), _ => lab::srgb_to_lab(self.display_rgb(c)), } @@ -490,7 +499,7 @@ impl Cms { Color::Rgb { .. } | Color::Lab { .. } => { let src = self.lab(c); match &self.cmyk { - CmykSpace::Generic(g) => g.gamut_error(src), + CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc), CmykSpace::Device => 0.0, CmykSpace::Icc(_) => { let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false); @@ -523,7 +532,7 @@ impl Cms { ProofTarget::Cmyk(name) => match cmyk_space(name) { Ok(sp) => { let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc); - Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper) + Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc) } Err(_) => srgb, }, @@ -534,7 +543,7 @@ impl Cms { pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] { match &proof.target { ProofTarget::Cmyk(name) => match cmyk_space(name) { - Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper), + Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc), Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper), }, _ => self.cmyk_to_srgb(cmyk, proof.simulate_paper), diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index dd9c137a0..99c16edf4 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() { assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]); } +/// With black-point compensation, sRGB black separates to the press's darkest black and that black +/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey). +#[test] +fn generic_cmyk_black_round_trips_with_bpc() { + let c = cms(); + let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric); + let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8); + assert_eq!(back, [0, 0, 0], "{cmyk:?}"); + // The paper still shows as white, and without compensation the press black stays a dark grey. + assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]); + let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false); + assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); +} + #[test] fn lab_colours_round_trip_through_the_cms() { let c = cms_no_bpc(); diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 2e5f099c2..4eca85781 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() { assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk); let p = IccProfile::from_bytes(None, &bytes).unwrap(); assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk)); - let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap(); + // The encoded profile converts without black-point compensation, so compare without it. + let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap(); for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] { let want = cms.cmyk_to_srgb(cmyk, false); let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap(); diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 421cd3c71..3d68dc044 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -340,7 +340,7 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_ assert_ne!(converted, flat(ink, 16)); let source = cms::active(); let dest = cms::Cms::new(&cms::ColorSettings { cmyk: DEVICE_CMYK.into(), ..source.settings().clone() }).unwrap(); - let want = dest.lab_to_cmyk(source.cmyk_to_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8); + let want = dest.lab_to_cmyk(source.cmyk_display_lab(ink.map(|v| v as f32 / 255.0)), source.settings().intent).map(|v| (v * 255.0).round() as u8); assert!(close(&converted[..4], &want, 3), "{converted:?} vs {want:?}"); // To RGB: the colour shown on screen. let (w, blob, _) = first(json!({"output": {"conversion": "destination", "destination": SRGB}})); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 3483e6a56..10e4f6eea 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -153,7 +153,7 @@ pub(crate) struct InkLut(CmykLut); impl InkLut { fn build(cms: &Cms) -> Self { Self(CmykLut::build(|inks| { - let [x, y, z] = lab::lab_to_xyz(cms.cmyk_to_lab(inks)); + let [x, y, z] = lab::lab_to_xyz(cms.cmyk_display_lab(inks)); [x, y, z, 0.0] })) } diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 3aa83398f..46c649f2e 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -1535,7 +1535,8 @@ fn proof_previews_draw_changes_as_a_new_renderer_does() { // A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate. let lab_doc = || { let mut d = proof_doc(); - let lab = Color::lab(50.0, 60.0, 40.0); + // Outside the press gamut, so its CMYK alternate looks different from the Lab colour. + let lab = Color::lab(50.0, 90.0, 70.0); let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true }; d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] }); let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 }; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4dbf73a8e..f2043138b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -570,7 +570,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). + - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. From 2a3efa02f27cb64904cc7dfb4b5706baa1b96041 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:48:59 +0700 Subject: [PATCH 023/184] Overprint Preview: overprinting characters draw with Darken too (#2415) --- crates/render/src/lib.rs | 4 ++-- crates/render/src/proof.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5791bd49b..24bf5a0e0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1773,12 +1773,12 @@ impl Renderer { return; } let tb = t.xf.transform_rect_bbox(g.bounds); - // Overprinting characters multiply per draw, like overprinting fills and strokes + // Overprinting characters darken per draw, like overprinting fills and strokes // ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer. let overprints = proof::overprints(f.opts); let overprint = |ctx: &mut RenderContext, on: bool| { if overprints { - ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() }); + ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Darken) } else { BlendMode::default() }); } }; // The object's own fills and strokes paint the whole outline: those below the Characters diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 86c0319ed..e308e2b4f 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -103,7 +103,7 @@ impl PartialEq for Plates { } } -/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply +/// What [`prepare`] does to the fills and strokes of each node: overprinting ones darken /// ([`darken_overprints`]), and with Separations Preview every color shows as the visible plates /// print it ([`plate_color`]). struct How<'a> { From ecbbba4f2e78ee0e5b0abac3cc978fa4e85a3521 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:31:51 +0200 Subject: [PATCH 024/184] Panels: floating panel groups paint an opaque background (#2421) The shared dock left its pane and tab strip backgrounds transparent because the docked card behind them is filled. Floating groups lie on the canvas, so the canvas showed through. They now paint the panel colour, which is what the card shows when docked. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panel_docking.rs | 8 ++++--- crates/ui-egui/src/panel_docking_tests.rs | 28 ++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index d01858d3c..9cb1cdbf6 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -644,9 +644,11 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } let mut style = craft_ui::docking::DockStyle::from_ui(ui); style.tab_height = 33.0; - // The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour. - style.background = egui::Color32::TRANSPARENT; - style.tab_background = egui::Color32::TRANSPARENT; + // The dock is a card (`theme::card`) filled with the panel colour. Floating groups lie on + // the canvas, not on that card, so panes and tab strip paint the same colour themselves; + // transparent they showed the canvas through (#2421). + style.background = t.panel; + style.tab_background = t.panel; style.active_background = t.button; style.text = t.text_strong; style.inactive_text = t.text_dim; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index c66623e75..9eccbf0e8 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -1,5 +1,5 @@ use super::*; -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable}; fn app() -> VectorcraftApp { @@ -614,3 +614,29 @@ fn the_window_menu_item_hides_a_shown_panel_and_shows_it_again() { app.run("window.panel", json!({"panel":"properties"})).unwrap(); assert!(shown(&app), "without the menu's toggle the command only shows the panel"); } + +/// A floating group lies on the canvas, not on the dock's card, so its tab strip and body paint +/// their own opaque background (#2421). +#[test] +fn floating_group_paints_an_opaque_background() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"properties","x":400,"y":200,"width":300,"height":420})).unwrap(); + let h = harness(app, vec2(1280.0, 800.0)); + // Paint order: the last big filled rectangle over a point decides what the user sees there + // (outlines and small widgets don't count). + fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option) { + match shape { + egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() => *found = Some(r.fill), + egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)), + _ => {} + } + } + let opaque_at = |p: Pos2| { + let mut found = None; + h.output().shapes.iter().for_each(|s| topmost(&s.shape, p, &mut found)); + found.is_some_and(|c| c.a() == 255) + }; + // Tab strip to the right of the tabs, and the body's empty bottom-right corner. + assert!(opaque_at(pos2(690.0, 210.0)), "tab strip is transparent"); + assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent"); +} From 47b6b3285db2252ebe0d697e7876f0046979aabe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:50:24 +0700 Subject: [PATCH 025/184] Panels: floating groups get an opaque fill beneath them; the docked card keeps its rounded corners (#2421) --- crates/ui-egui/src/panel_docking.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 9cb1cdbf6..7e1df99c4 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -625,6 +625,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { display .floating .retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id)))); + // Floating groups lie on the canvas, not on the dock's card: reserve a fill under each one + // before the dock draws it, sized once it is placed, so the canvas doesn't show through (#2421). + let fills: Vec<_> = display + .floating + .iter() + .filter_map(|group| group.panels.first()) + .map(|first| { + let layer = egui::LayerId::new(egui::Order::Middle, area.with(("floating", first))); + (layer, ui.ctx().layer_painter(layer).add(egui::Shape::Noop)) + }) + .collect(); let mut draw = |ui: &mut egui::Ui| { if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) { let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed})); @@ -644,11 +655,10 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } let mut style = craft_ui::docking::DockStyle::from_ui(ui); style.tab_height = 33.0; - // The dock is a card (`theme::card`) filled with the panel colour. Floating groups lie on - // the canvas, not on that card, so panes and tab strip paint the same colour themselves; - // transparent they showed the canvas through (#2421). - style.background = t.panel; - style.tab_background = t.panel; + // The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour + // (square fills would cover its rounded corners). Floating groups get the fills below. + style.background = egui::Color32::TRANSPARENT; + style.tab_background = egui::Color32::TRANSPARENT; style.active_background = t.button; style.text = t.text_strong; style.inactive_text = t.text_dim; @@ -661,7 +671,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { style.resize_label = tl!("Resize panels").to_string(); style.resize_window_label = tl!("Resize panel window").to_string(); let mut content = PanelContent { app, commands: Vec::new() }; - let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized( + let output = craft_ui::docking::DockArea::new(area).show_customized( ui, &display, &style, @@ -756,6 +766,11 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect)); } } + for (layer, fill) in fills { + if let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(layer.id)) { + ui.ctx().layer_painter(layer).set(fill, egui::Shape::rect_filled(rect, 0.0, t.panel)); + } + } icon_rail(app, ui); crate::floating::docked_columns(app, ui); true From 424a49604cc3131a4c5bab9d32bcca257e957580 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:49:08 +0700 Subject: [PATCH 026/184] Layers: Merge Selected keeps to the highlighted layers, as documented (#2383) Reverts the fallback that merged the layers of art selected on the canvas when no layer row is highlighted: Illustrator's Merge Selected merges the highlighted rows into the last one highlighted. --- crates/engine/src/cmd/layerpanel.rs | 13 ++----------- crates/engine/src/tests_layers.rs | 18 ------------------ crates/ui-egui/src/panels/layers.rs | 4 +--- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 66cf37ff7..c302a7409 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -263,22 +263,13 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { fn merge(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.merge"; - let mut ids = rows_param(s, p, C)?; + let ids = rows_param(s, p, C)?; let st = s.doc()?; let d = &st.doc; - // Nothing named or highlighted, but art selected on the canvas in several layers (Select All): - // merge those layers (#2383). - if p.get("ids").is_none() && p.get("id").is_none() && st.highlighted_rows().is_empty() { - let held: HashSet = st.selection.objects.iter().filter_map(|o| d.layer_of(*o)).collect(); - let layers: Vec = d.layers.iter().map(|l| l.id).filter(|l| held.contains(l)).collect(); - if layers.len() >= 2 { - ids = layers; - } - } let mut seen = HashSet::new(); let ids: Vec = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect(); if ids.len() < 2 { - return Err(bad(C, "highlight two or more rows to merge, or select art in two or more layers")); + return Err(bad(C, "highlight two or more rows to merge")); } // The last highlighted layer that is not inside another highlighted row: with a layer and its // sublayer both highlighted (Select All in the panel), the sublayer travels with its layer diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 7591873d2..1fc789c80 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -625,24 +625,6 @@ fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level } } -/// Merge Selected with no highlighted rows but art selected in several layers (Select All on the -/// canvas) merges those layers; art in one layer still asks for rows (#2383). -#[test] -fn merge_selected_with_art_selected_in_several_layers_merges_those_layers() { - let mut s = session(); - let a = rect(&mut s, 0.0); - run(&mut s, "layer.new", json!({"top": true})); - let b = rect(&mut s, 100.0); - run(&mut s, "select.set", json!({"ids": [b.0]})); - run(&mut s, "layer.highlight", json!({"ids": []})); - assert!(s.execute("layer.merge", &json!({})).is_err()); - run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); - let into = id(&run(&mut s, "layer.merge", json!({}))); - let st = s.doc().unwrap(); - assert_eq!(st.doc.layers.len(), 1); - assert!(st.doc.layer_of(a) == Some(into) && st.doc.layer_of(b) == Some(into)); -} - /// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a /// layer, and on top of the layer holding its group when it is in a group. When that row is in a /// hidden or locked layer, the selected art it collects from another layer is deselected, the key diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0642274ea..7fe651ced 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1461,8 +1461,6 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let rows = st.highlighted_rows(); let current = st.current_layer(); let has_sel = !st.selection.is_empty(); - // How many top-level layers hold the selected art: Merge Selected merges them. - let art_layers = st.selection.objects.iter().filter_map(|o| doc.layer_of(*o)).collect::>().len(); let isolated = st.isolation.is_some(); let targets: Vec = if rows.is_empty() { current.into_iter().collect() } else { rows.clone() }; let one_container = match targets.as_slice() { @@ -1500,7 +1498,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { None, Some(("Locate Object", "layer.locate", json!({}), has_sel, false)), None, - Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2 || (rows.is_empty() && art_layers >= 2), false)), + Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). From 39979d47531d6125b948becead089bba8a59e6e4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:20:19 +0700 Subject: [PATCH 027/184] Tests: the floating-panel test skips fills that paint nothing (#2421) --- crates/ui-egui/src/panel_docking_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 9eccbf0e8..f0a556d51 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -623,10 +623,10 @@ fn floating_group_paints_an_opaque_background() { app.run("window.panel.float", json!({"panel":"properties","x":400,"y":200,"width":300,"height":420})).unwrap(); let h = harness(app, vec2(1280.0, 800.0)); // Paint order: the last big filled rectangle over a point decides what the user sees there - // (outlines and small widgets don't count). + // (outlines, small widgets and fully transparent fills, which paint nothing, don't count). fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option) { match shape { - egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() => *found = Some(r.fill), + egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 200.0 && r.stroke.is_empty() && r.fill.a() > 0 => *found = Some(r.fill), egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)), _ => {} } From 577239d734bf2c556efc6cb897c44982f269fb39 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:26:32 +0200 Subject: [PATCH 028/184] Gradient chips: follow the angle and the gradient type (#2494) gradient_chip now takes the GradientPaint and lays the gradient out as it would be on an object of the chip's shape: linear along its angle, radial from the centre, freeform from its colour points. Gradient panel swatch menu and the Swatch Options dialog carry the full paint. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/swatch_options.rs | 4 +- crates/ui-egui/src/panels/gradient.rs | 6 +- crates/ui-egui/src/toolbar.rs | 2 +- crates/ui-egui/src/widgets.rs | 118 +++++++++++++++---- 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 9d6feca10..07674ef99 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -76,7 +76,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { Ok(Value::Null) } Paint::Gradient(g) => { - let fields = json!({"__swatch": name, "name": name, "__gradient": g.gradient, "preview": true}); + let fields = json!({"__swatch": name, "name": name, "__gradient": g, "preview": true}); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) } @@ -105,7 +105,7 @@ fn edit_params(d: &Dialog) -> Value { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::(g.clone()).ok()) { + let changed = match d.fields.get("__gradient").and_then(|g| serde_json::from_value::(g.clone()).ok()) { Some(g) => { let changed = grid(ui, |ui| name_row(ui, d)); ui.add_space(6.0); diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index abf1f60ac..0c91c0089 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -165,14 +165,14 @@ fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) { } /// The document's gradient swatches (groups included): name and gradient. -fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, Gradient)> { +fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, GradientPaint)> { let Some(st) = app.session.active() else { return vec![] }; let d = &st.doc; d.swatches .iter() .chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter())) .filter_map(|s| match &s.paint { - Paint::Gradient(g) => Some((s.name.clone(), g.gradient.clone())), + Paint::Gradient(g) => Some((s.name.clone(), (**g).clone())), _ => None, }) .collect() @@ -370,7 +370,7 @@ fn freeform_section(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint) { fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click_and_drag()); - widgets::gradient_chip(ui, r, &g.gradient); + widgets::gradient_chip(ui, r, g); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); let resp = resp.on_hover_text(tl!("Gradient Fill: click to apply, drag onto art")); if resp.clicked() && !is_grad { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0f9de6456..e46b86498 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -471,7 +471,7 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz let (r, resp) = ui.allocate_exact_size(vec2(chip, chip), Sense::click()); match cmd { "paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)), - "paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient.gradient), + "paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient), _ => widgets::paint_chip(ui, r, &Paint::None), } if resp.on_hover_text(tl!(tip)).clicked() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 945c2c3d7..3d0096cd5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -554,7 +554,7 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { let [r, g, b, _] = color.to_rgba8(1.0); p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); } - Paint::Gradient(gp) => gradient_chip(ui, rect, &gp.gradient), + Paint::Gradient(gp) => gradient_chip(ui, rect, gp), Paint::Pattern { .. } => { p.rect_filled(rect, 0.0, Color32::from_gray(200)); for i in 0..4 { @@ -565,32 +565,68 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { } } -/// Draw a gradient preview into `rect` (linear left to right; radial from the centre). -pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient) { - let p = ui.painter(); - let radial = gradient.kind == vectorcraft_color::GradientKind::Radial; - if radial { - // Outer colour fills the corners beyond the largest circle. - let (c, _) = gradient.sample(1.0); - let [r, g, b, _] = c.to_rgba8(1.0); - p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); +/// Draw a gradient preview into `rect`, laid out as the gradient would be on an object of that +/// shape: linear along its angle, radial from the centre, freeform from its colour points. +pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::GradientPaint) { + if !(rect.width() > 0.0 && rect.height() > 0.0) { + return; } - let n = 24; - let w = rect.width() / n as f32; - for i in 0..n { - let tt = (i as f32 + 0.5) / n as f32; - if radial { - let (c, _) = gradient.sample(1.0 - tt); - let [r, g, b, _] = c.to_rgba8(1.0); - let s = rect.width().min(rect.height()) * tt / 2.0; - p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b)); - } else { - let (c, _) = gradient.sample(tt); - let [r, g, b, _] = c.to_rgba8(1.0); - let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height())); - p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b)); + // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). + let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); + let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); + let mut mesh = egui::Mesh::default(); + for (k, color) in colors.into_iter().enumerate() { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + let at = pos2(rect.left() + rect.width() * i as f32 / cols as f32, rect.top() + rect.height() * j as f32 / rows as f32); + mesh.colored_vertex(at, color); + } + for j in 0..rows as u32 { + for i in 0..cols as u32 { + let v = j * (cols as u32 + 1) + i; + mesh.add_triangle(v, v + 1, v + cols as u32 + 1); + mesh.add_triangle(v + 1, v + cols as u32 + 2, v + cols as u32 + 1); } } + ui.painter().add(egui::Shape::mesh(mesh)); +} + +/// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` +/// chip showing `gp` as it would look on an object of that size. +fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec { + use vectorcraft_color::{GradientGeom, GradientKind}; + let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); + let rgb = |c: vectorcraft_color::Color| { + let [r, g, b, _] = c.to_rgba8(1.0); + Color32::from_rgb(r, g, b) + }; + let at = |k: usize| { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + vectorcraft_geom::kurbo::Point::new(b.width() * i as f64 / cols as f64, b.height() * j as f64 / rows as f64) + }; + let n = (cols + 1) * (rows + 1); + if gp.gradient.kind == GradientKind::Freeform { + let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] }; + let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted)); + return (0..n) + .map(|k| { + let ([r, g, b], _) = field.sample(at(k)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(r), q(g), q(b)) + }) + .collect(); + } + // A placed gradient keeps its direction; a fresh one is fitted to the chip at its angle. + let angle = gp.geom.map_or(gp.angle, |g| g.angle_deg()); + let geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 }); + (0..n) + .map(|k| { + let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; + rgb(gp.gradient.sample(t).0) + }) + .collect() } /// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a @@ -2041,3 +2077,37 @@ mod menu_room_tests { assert_eq!(menu_room(300.0, 290.0), 120.0); } } + +#[cfg(test)] +mod gradient_chip_tests { + use super::*; + use vectorcraft_color::{Gradient, GradientKind, GradientPaint}; + + fn paint(kind: GradientKind, angle: f64) -> GradientPaint { + let mut gp = GradientPaint::new(Gradient { kind, ..Gradient::default() }); + gp.angle = angle; + gp + } + + #[test] + fn linear_chip_follows_the_angle() { + // 5 x 5 grid points on a 20 x 20 chip; White (start) to Black (end). + let at = |colors: &[Color32], i: usize, j: usize| colors[j * 5 + i]; + let side = gradient_chip_colors(&paint(GradientKind::Linear, 0.0), 20.0, 20.0, 4, 4); + assert_eq!(at(&side, 0, 2), Color32::WHITE); + assert_eq!(at(&side, 4, 2), Color32::BLACK); + assert_eq!(at(&side, 2, 0), at(&side, 2, 4)); + // 90 degrees runs bottom to top: constant along rows, white at the bottom. + let up = gradient_chip_colors(&paint(GradientKind::Linear, 90.0), 20.0, 20.0, 4, 4); + assert_eq!(at(&up, 0, 4), Color32::WHITE); + assert_eq!(at(&up, 0, 0), Color32::BLACK); + assert_eq!(at(&up, 0, 2), at(&up, 4, 2)); + } + + #[test] + fn radial_chip_runs_from_the_centre_out() { + let colors = gradient_chip_colors(&paint(GradientKind::Radial, 0.0), 20.0, 20.0, 4, 4); + assert_eq!(colors[2 * 5 + 2], Color32::WHITE); + assert_eq!(colors[0], Color32::BLACK); + } +} From 91fa3af21d3ba4ab94f88ad810aeb42a4df15496 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:03:42 +0200 Subject: [PATCH 029/184] Export: raster document.export takes a page rectangle (rect) (#2412) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 35 ++++++++++++++++++ crates/engine/src/cmd/fileio/mod.rs | 20 ++++++++--- crates/engine/src/cmd/fileio/tests_raster.rs | 38 ++++++++++++++++++++ crates/engine/tests/command_sweep.rs | 5 +++ docs/mcp.md | 2 +- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 7c26de22a..f37521efa 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result> { } } +/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when +/// absent; an error when it is not four finite numbers with a positive size, when the format isn't +/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would +/// contradict it). The image size limits are checked with the scale, as for an artboard. +fn page_rect(f: &Format, p: &Value) -> Result> { + let v = match p.get("rect") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + if !f.raster { + return Err(bad(C, format!("rect applies to raster formats, not {}", f.label))); + } + if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) { + return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards"))); + } + let nums: Option> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect()); + let Some(&[x, y, w, h]) = nums.as_deref() else { + return Err(bad(C, "rect is [x, y, width, height] in points")); + }; + let (x1, y1) = (x + w, y + h); + if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) { + return Err(bad(C, "rect needs finite numbers and a positive width and height")); + } + Ok(Some(Rect::new(x, y, x1, y1))) +} + /// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds). pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document { with_single_artboard(doc.clone(), rect, name) @@ -408,6 +434,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; + // `rect`: that area of the page, as if it were the only artboard (the document is not changed). + if let Some(rect) = page_rect(f, p)? { + let mut q = super::export::without_artboards(p); + if let Some(o) = q.as_object_mut() { + o.remove("rect"); + o.remove("useArtboards"); + } + return encode_files(&single_artboard(doc, rect, "Rect"), f, &q); + } if use_artboards == Some(false) { let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; let mut q = super::export::without_artboards(p); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b85fa7306..b20e0db25 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -311,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption { default: "null", description: "true: every chosen artboard (default all), one file each named -. (PDF and AI: one page each); false: the bounds of the visible art", }; +const RECT: FormatOption = FormatOption { + name: "rect", + ty: "array", + default: "null", + description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards", +}; const PPI: FormatOption = FormatOption { name: "ppi", ty: "number", @@ -398,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -419,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption { description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)", }; const TIFF_OPTIONS: &[FormatOption] = - &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC]; + &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC]; const BMP_COLOR_MODEL: FormatOption = FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" }; const BMP_DEPTH: FormatOption = FormatOption { @@ -447,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -466,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption { default: "24", description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)", }; -const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH]; +const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH]; const PSD_LAYERS: FormatOption = FormatOption { name: "layers", ty: "boolean", @@ -490,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -560,7 +569,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC], }, Format { id: "jpg", @@ -575,6 +584,7 @@ pub const FORMATS: &[Format] = &[ ARTBOARDS, RANGE, USE_ARTBOARDS, + RECT, PPI, SCALE, BACKGROUND, @@ -596,7 +606,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], + options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], }, Format { id: "tiff", diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index fbcb9ad58..496b16f81 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -295,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() { let warnings = r["warnings"].as_array().cloned().unwrap_or_default(); assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}"); } + +#[test] +fn export_rect_writes_that_page_area_and_leaves_the_document_alone() { + let mut s = session(200.0, 400.0, 1); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap(); + let boards = s.doc().unwrap().doc.artboards.len(); + let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap(); + let img = rgba(&b64(&r)); + assert_eq!((img.width(), img.height()), (40, 300)); + assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art"); + assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left"); + // Twice the resolution, and a window that holds both art and empty page. + let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap())); + assert_eq!((img.width(), img.height()), (80, 20)); + assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255)); +} + +#[test] +fn export_rect_refuses_what_cannot_be_exported() { + let mut s = session(200.0, 400.0, 1); + s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap(); + let bad = [ + json!({"format": "png", "rect": [0, 0, 0, 10]}), + json!({"format": "png", "rect": [0, 0, -5, 10]}), + json!({"format": "png", "rect": [0, 0, 10]}), + json!({"format": "png", "rect": [0, 0, "a", 10]}), + json!({"format": "png", "rect": "20,40,40,300"}), + json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}), + json!({"format": "png", "rect": [0, 0, 100000, 100000]}), + json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}), + json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}), + json!({"format": "svg", "rect": [0, 0, 10, 10]}), + ]; + for p in bad { + assert!(s.execute("document.export", &p).is_err(), "{p}"); + } +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index b8cc7f115..0bf31cecb 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -189,6 +189,11 @@ fn every_command_with_junk_params() { fn structured_junk() { let cases: Vec<(&str, Value)> = vec![ ("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})), + ("document.export", json!({"format": "png", "rect": [1e308, -1e308, 1e308, 1e308]})), + ("document.export", json!({"format": "png", "rect": [0, 0, 1e300, 1e300], "scale": 64})), + ("document.export", json!({"format": "tiff", "rect": [null, "x", -1, 0.0000001], "useArtboards": true})), + ("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})), + ("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})), ("select.add", json!({"ids": [[1], {"a": 1}]})), ("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})), ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})), diff --git a/docs/mcp.md b/docs/mcp.md index bed84a08a..bee14e10e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -318,7 +318,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). | From 7c80b286c7a419d872258f9a4eb27726732b3c1d Mon Sep 17 00:00:00 2001 From: Brian Torres Date: Fri, 16 Oct 2026 21:18:14 -0700 Subject: [PATCH 030/184] =?UTF-8?q?Fix=20#2540:=20Object=20=E2=86=92=20Exp?= =?UTF-8?q?and=20expands=20Intertwine=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand had nothing for Intertwine groups. It reported success but left the group live (it only dropped the members' live shapes), and its Stroke and Fill options could not reach the members, so a second Expand found nothing to do. Ungroup takes apart plain groups only, so it did nothing either. Expand's Object option now turns every Intertwine group in the selection (inside groups too, inner ones first) into a plain group of what it draws: its objects, keeping their ids and order, then each part drawn over the others as a clip group. The group keeps its id, place, transparency and appearance, everything happens in one undo step, and the dialog enables Object for an Intertwine group. Ungroup then takes the result apart, as with an expanded blend or repeat. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/expand.rs | 15 +++++-- crates/engine/src/cmd/intertwine.rs | 38 ++++++++++++++++- crates/engine/src/cmd/live.rs | 2 +- crates/engine/src/tests_intertwine.rs | 61 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 863924719..da1567ce7 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -1,5 +1,5 @@ //! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills -//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the +//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the //! art they show, their resources joining the document. A linear or radial gradient fill becomes a //! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform //! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling. @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -108,6 +108,8 @@ struct Expandable { placed: bool, /// Repeats (Object expands them into groups of their instances). repeats: bool, + /// Intertwine groups (Object expands them into groups of what they draw). + intertwines: bool, } impl Expandable { @@ -123,6 +125,7 @@ impl Expandable { e.blends |= matches!(c.kind, NodeKind::Blend { .. }); e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); + e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. }); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), @@ -134,7 +137,7 @@ impl Expandable { } fn object(&self) -> bool { - self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats + self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines } } @@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; s.edit("Expand", |d, _| expand_placed_under(d, &roots))?; } + // Intertwine groups before what they hold: their objects, and the copies of the parts drawn + // over the others, expand below. + if object && e.intertwines { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?; + } // Blends first: what their steps hold (type, effects, strokes, gradients) expands below. if object && e.blends { let roots = selected_roots(s)?; diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 566cb7b20..8947a97b5 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -5,7 +5,8 @@ //! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn //! around them (`area`), and changes which member is drawn on top there: each member keeps the //! area where it is drawn over the others (`NodeExtras::intertwine`, see -//! `vectorcraft_doc::intertwine`). +//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it +//! draws ([`expand_intertwines`]), which Ungroup takes apart. //! //! An overlap area is a face of the planar arrangement of what the members paint (their fills and //! strokes, type outlined; other objects by their bounds): the part where two or more of them @@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +// ---------- Expand ---------- + +/// Replace Intertwine group `id` by a plain group of the art it draws +/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a +/// clip group), keeping everything about the group itself; the generated art gets ids of its own. +fn expand_group(d: &mut Document, id: NodeId) -> Result<()> { + let Some(mut g) = d.node(id).cloned() else { return Ok(()) }; + let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) }; + g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false }; + super::live::fix_ids(d, &mut g); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art +/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how +/// many. A live Intertwine is expanded before it can be ungrouped: +/// . +pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result { + let mut groups = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if is_intertwine(c) { + groups.push(c.id); + } + }); + } + } + for g in groups.iter().rev() { + expand_group(d, *g)?; + } + Ok(groups.len()) +} + // ---------- Arrange ---------- #[derive(Clone, Copy, PartialEq)] diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index e1135c00e..0c88f7214 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec) -> Result = ids.iter().collect(); + assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]); + assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left"); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did"); + // Plain art: Ungroup takes it apart. + s.execute("object.ungroup", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).is_none()); + assert_eq!(s.doc().unwrap().selection.objects.len(), 3); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70)); + // Undo brings the Intertwine group back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. })); +} + +/// Expand reaches an Intertwine group inside a selected group, and with every option on also +/// outlines the strokes of the rings and of the part drawn over the other one. +#[test] +fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() { + let mut s = session(); + let (g, ..) = intertwined(&mut s); + arrange(&mut s, json!({"at": [50, 30]})); + let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()); + select(&mut s, &[g, c]); + let outer = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.expand", &json!({})).unwrap(); + let (mut intertwines, mut strokes) = (0, 0); + s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| { + intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. })); + strokes += usize::from(!n.appearance.stroke_paint().is_none()); + }); + assert_eq!((intertwines, strokes), (0, 0)); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did"); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand"); +} + #[test] fn the_arranged_areas_move_with_the_group() { let mut s = session(); From fbcb217789bece8678eef9dab8e45989b9b59501 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 20:43:42 -0700 Subject: [PATCH 031/184] Part of #2384: imported PDF text keeps its place after wide gaps between words PDF import reads a gap wider than a fifth of an em between two glyphs as a space, and a space that the line's tracking cannot place gets tracking of its own. That tracking was clamped to 1000 thousandths of an em, while type takes tracking from -1000 to 10000. A gap wider than an em plus the width of a space came in short by the excess, so the words after it sat to the left of where the file put them, and each further wide gap added to the shift. The tracking that import sets for such a space, and for the line around it, now has the same range as type's. --- crates/pdf/src/import_text.rs | 2 +- crates/pdf/src/tests_import_lines.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 04d25c0b0..04c5b3129 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -494,7 +494,7 @@ fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, s tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::()) / rest } else { 0.0 }; } own.sort_by_key(|(b, _)| *b); - let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round(); + let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 10000.0).round(); (em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect()) } diff --git a/crates/pdf/src/tests_import_lines.rs b/crates/pdf/src/tests_import_lines.rs index fddb46221..898dcaa23 100644 --- a/crates/pdf/src/tests_import_lines.rs +++ b/crates/pdf/src/tests_import_lines.rs @@ -191,6 +191,20 @@ fn spaces_read_from_wide_gaps_keep_the_glyphs_after_them_in_place() { assert!((at(9) - f64::from(x2)).abs() < 0.05, "the third group starts where the file has it: {} vs {x2}", at(9)); } +#[test] +fn words_set_ems_apart_keep_their_place() { + // Gaps of about 1.75 and 2.6 em (#2384): each space's own tracking is more than an em. + let (x1, x2) = (48.0, 84.0); + let bytes = source_sans(&[(20.0, 50.0, 10.0, "ab"), (x1, 50.0, 10.0, "cd"), (x2, 50.0, 10.0, "ef")]); + let t = texts(&import(&bytes).unwrap()); + assert_eq!(t.len(), 1); + assert_eq!(t[0].plain_text(), "ab cd ef"); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]); + let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x; + assert!((at(3) - f64::from(x1)).abs() < 0.05, "the second word starts where the file has it: {} vs {x1}", at(3)); + assert!((at(6) - f64::from(x2)).abs() < 0.05, "the third word starts where the file has it: {} vs {x2}", at(6)); +} + /// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30). fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document { let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; From 3c5d0e7ce436cf6ea660cc4f43ed8aa3f7e3892c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 21:16:07 -0700 Subject: [PATCH 032/184] Libraries: a plain click sets where a Shift-click range starts A plain click on an item in the library panel selected the item but did not record it as the anchor that a Shift-click ranges from. A later Shift-click then selected only the clicked item, or ranged from an older anchor set by an earlier Cmd/Ctrl-click or folder click. This applied to swatch libraries and graphic style libraries. Every click on an item or folder now goes through click_selection, as in the Swatches panel. A plain click still selects the item alone and adds and applies it. --- crates/ui-egui/src/panels/library_panel.rs | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 00ca76fbb..0d0c1f2af 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -274,10 +274,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, lib: &K }); }); if let Some((name, folder, m)) = clicked { - // A plain click on an item adds and applies it; folders and modifier clicks select. + // A plain click on an item also adds and applies it; folders and modifier clicks only select. let only_select = folder || m.shift || m.command; let order: Vec<&str> = rows.iter().map(|r| r.name).collect(); - let sel = if only_select { click_selection(ui, &key::("anchor"), selected.clone(), &order, &name, m) } else { vec![name.clone()] }; + let sel = click_selection(ui, &key::("anchor"), selected, &order, &name, m); set_selection::(ui.ctx(), id, sel); if !only_select { K::click(app, ui, id, &name); @@ -485,6 +485,26 @@ mod tests { assert!(selection::(&ctx, "brights").is_empty()); } + #[test] + fn a_plain_click_sets_where_a_shift_click_range_starts() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); + let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); + click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); + click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); + assert_eq!(selection::(&ctx, "pastels"), order[1..=4], "the range starts at the plain click"); + // A plain click after a Cmd/Ctrl-click moves the anchor from the Cmd/Ctrl-clicked item. + click(&mut app, &ctx, &order[8], Modifiers::COMMAND, 4.0); + click(&mut app, &ctx, &order[2], Modifiers::NONE, 6.0); + click(&mut app, &ctx, &order[3], Modifiers::SHIFT, 8.0); + assert_eq!(selection::(&ctx, "pastels"), order[2..=3]); + let d = &app.session.doc().unwrap().doc; + assert!(d.swatch(&order[2]).is_some(), "a plain click still adds the swatch"); + assert!(d.swatch(&order[3]).is_none(), "a Shift-click only selects"); + } + #[test] fn find_filters_list_view_and_arrows_step_through_the_libraries() { let mut app = app(); From a8fbae1b1f3f1009b5e44c7019a427184e08c389 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 20:04:15 -0700 Subject: [PATCH 033/184] Graphic Styles: Shift-click selects a range of styles In the Graphic Styles panel, Shift-click and Cmd/Ctrl-click both added or removed the one style clicked. Shift-click now adds to the selection every style from the last one clicked without Shift through the clicked one, in the order the panel lists them, and Cmd/Ctrl-click still adds or removes one. A plain click applies the style and selects it alone, Alt-click still adds it on top, and the Control bar's Style picker still applies on every click. The panel uses swatches::click_selection, as the Swatches panel and the library panel do. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/panels/graphic_styles.rs | 90 +++++++++++++++------ docs/target-app-parity.md | 1 + 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index dcb0b6bb9..6e488ca65 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -1,11 +1,12 @@ //! Graphic Styles panel: the document's styles as rendered thumbnails (Thumbnail View) or rows //! (Small and Large List View), shown on a square or on type. Clicking a style applies it to the -//! selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click opens -//! Graphic Style Options. Dragging a style moves it in the list, or applies it to the art it is -//! dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance panel's -//! thumbnail dropped on the panel becomes a new style. Holding the right button on a style shows it -//! large, on the selected object when there is one. The Control bar's Style picker lists the same -//! tiles ([`picker`]). Graphic style libraries open in the library panel +//! selection (Alt-click adds it on top); Shift-click adds to the panel's selection every style from +//! the last one clicked without Shift through this one; Cmd/Ctrl-click adds or removes one; +//! double-click opens Graphic Style Options. Dragging a style moves it in the list, or applies it +//! to the art it is dropped on (`canvas::panel_drop`); art dragged off the canvas or the Appearance +//! panel's thumbnail dropped on the panel becomes a new style. Holding the right button on a style +//! shows it large, on the selected object when there is one. The Control bar's Style picker lists +//! the same tiles ([`picker`]). Graphic style libraries open in the library panel //! ([`GraphicStyleLibraries`]). use std::cell::RefCell; @@ -270,8 +271,8 @@ fn large_preview(app: &VectorcraftApp, ctx: &egui::Context, s: &Shown, i: usize) /// What a click on a style does. enum Click { - Apply(String), - Toggle(String), + /// A click with the modifiers held. + Pick(String, egui::Modifiers), Options(String), } @@ -294,8 +295,7 @@ fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option { if resp.double_clicked() { Some(Click::Options(name.to_string())) } else if resp.clicked() { - let m = ui.input(|i| i.modifiers); - Some(if m.shift || m.command { Click::Toggle(name.to_string()) } else { Click::Apply(name.to_string()) }) + Some(Click::Pick(name.to_string(), ui.input(|i| i.modifiers))) } else { None } @@ -419,24 +419,19 @@ fn act(app: &mut VectorcraftApp, ui: &Ui, s: &Shown, ev: Events, picker: bool) { run(app, "graphicStyle.new", json!({ "id": id.0 })); } match ev.click { - Some(Click::Apply(name) | Click::Toggle(name)) if picker => { + Some(Click::Pick(name, _)) if picker => { app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); ui.close(); } - Some(Click::Apply(name)) => { - // With nothing selected the next object drawn takes the style. - app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); - set_pstate(ui.ctx(), "gs-sel", vec![name]); - } - Some(Click::Toggle(name)) => { - let mut sel = selected(ui.ctx(), s.d); - match sel.iter().position(|n| *n == name) { - Some(i) => { - sel.remove(i); - } - None => sel.push(name), - } + Some(Click::Pick(name, m)) => { + let order: Vec<&str> = s.d.graphic_styles.iter().map(|g| g.name.as_str()).collect(); + let sel = super::swatches::click_selection(ui, "gs-anchor", selected(ui.ctx(), s.d), &order, &name, m); set_pstate(ui.ctx(), "gs-sel", sel); + // A plain click also applies the style (with nothing selected the next object drawn + // takes it); Shift and Cmd/Ctrl clicks only select. + if !(m.shift || m.command) { + app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok(); + } } Some(Click::Options(name)) => { app.run("ui.graphicStyleOptions", json!({ "name": name })).ok(); @@ -938,6 +933,53 @@ mod tests { assert_eq!(d.node(rect).unwrap().extras().graphic_style, d.graphic_styles.last().map(|g| g.id)); } + #[test] + fn shift_click_selects_a_range_and_cmd_click_adds_or_removes_one_style() { + let mut app = app(); + app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap(); + let n = names(&app); + assert_eq!(n.len(), 5); + let ctx = egui::Context::default(); + set_pstate(&ctx, "gs-view", View::SmallList); + frame(&ctx, |ui| show(&mut app, ui)); + // A click on row `i` (Small List View: rows of 18 points, 3 apart) with `modifiers` held, + // a second after the last one so that two clicks on a row are not a double-click. + let mut time = 0.0; + let mut click = |app: &mut VectorcraftApp, i: usize, modifiers: egui::Modifiers| { + let at = pos2(60.0, 10.0 + 21.0 * i as f32); + time += 1.0; + for pressed in [true, false] { + let button = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 600.0))), + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(at), button], + time: Some(time + if pressed { 0.0 } else { 0.05 }), + ..Default::default() + }; + ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear(); + } + selected(&ctx, &app.session.active().unwrap().doc.clone()) + }; + let linked = |app: &VectorcraftApp| app.session.selection_graphic_style().map(|(g, _)| g.name.clone()); + // A plain click applies the style and selects it alone. + assert_eq!(click(&mut app, 0, egui::Modifiers::NONE), [n[0].clone()]); + assert_eq!(linked(&app).as_ref(), Some(&n[0])); + // Shift-click selects from that style to the clicked one, and applies nothing. + assert_eq!(click(&mut app, 3, egui::Modifiers::SHIFT), n[..4]); + assert_eq!(linked(&app).as_ref(), Some(&n[0])); + // Cmd/Ctrl-click removes a selected style and adds it back. + assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone()]); + assert_eq!(click(&mut app, 2, egui::Modifiers::COMMAND), [n[0].clone(), n[1].clone(), n[3].clone(), n[2].clone()]); + // Shift-click extends from the style Cmd/Ctrl-clicked last. + click(&mut app, 0, egui::Modifiers::NONE); + click(&mut app, 2, egui::Modifiers::COMMAND); + assert_eq!(click(&mut app, 4, egui::Modifiers::SHIFT), [n[0].clone(), n[2].clone(), n[3].clone(), n[4].clone()]); + assert_eq!(linked(&app).as_ref(), Some(&n[0])); + // A plain click applies the next style. + assert_eq!(click(&mut app, 4, egui::Modifiers::NONE), [n[4].clone()]); + assert_eq!(linked(&app).as_ref(), Some(&n[4])); + } + #[test] fn the_right_button_shows_a_large_preview_and_the_picker_applies_styles() { let mut app = app(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f2043138b..6515232eb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -586,6 +586,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. + In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included. From 34ae88292df7ee97e3453a1df0fcea6312025f29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 20:23:11 -0700 Subject: [PATCH 034/184] Links: Shift-click selects a range of links In the Links panel, Shift-click and Cmd/Ctrl-click both added or removed the image of the one row clicked. Shift-click now selects the images of every row from the last row clicked without Shift to the clicked one, in the order the panel shows them (its Show filter and Sort order), and Cmd/Ctrl-Shift-click adds them to the selection. Cmd/Ctrl-click still adds or removes one image, a plain click selects one, and a double-click still goes to the link. When the last row clicked is not shown, or the last click was in another document, Shift-click acts as a click without Shift. A click on a row runs select.set, select.add or select.toggle, and the range is worked out as in the Layers panel's Shift-click. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/panels/links.rs | 48 ++++++++++++++------ crates/ui-egui/src/panels/tests_links.rs | 57 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 93 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index dcf9f024c..7868c2e8e 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -1,9 +1,11 @@ //! Links panel: every image in the document, linked files with their status (missing, modified) //! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects -//! its image; the buttons Relink, Go To, Update and Edit Original act on the selected rows; Link -//! Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find -//! Linked Files: `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort, -//! thumbnail size and Show in Folder. +//! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows +//! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink, +//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the +//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: +//! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Show filters, Sort, thumbnail size +//! and Show in Folder. //! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -245,6 +247,31 @@ fn status_tip(r: &Value) -> String { } } +/// A click on the row of image `id` among the shown `rows`: plain selects the image alone, +/// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked +/// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A +/// Shift-click while that row is not shown, or after the last click was in another document, acts +/// as a click without Shift. +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) { + let Some(uid) = app.session.active().map(|st| st.uid) else { return }; + let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor"); + let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id)); + if m.shift + && let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid) + && let (Some(i), Some(j)) = (at(a), at(id)) + { + let range: Vec = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect(); + app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok(); + return; + } + set_pstate(ctx, "links-anchor", Some((uid, id))); + if m.command { + app.run("select.toggle", json!({ "id": id })).ok(); + } else { + app.run("select.set", json!({ "ids": [id] })).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let o = options(ui.ctx()); @@ -257,7 +284,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let thumbs = super::layers::ThumbSource::of(app, st, &doc); let selected = selected_images(app); let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); - let mut clicked: Option<(u64, bool, bool)> = None; + let mut clicked: Option<(u64, egui::Modifiers, bool)> = None; widgets::list_box(ui, |ui| { ui.set_min_height(110.0); ui.set_width(ui.available_width()); @@ -317,21 +344,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ); let resp = resp.on_hover_text(status_tip(r)); if resp.double_clicked() { - clicked = Some((id, false, true)); + clicked = Some((id, egui::Modifiers::NONE, true)); } else if resp.clicked() { - clicked = Some((id, ui.input(|i| i.modifiers.shift || i.modifiers.command), false)); + clicked = Some((id, ui.input(|i| i.modifiers), false)); } } }); }); match clicked { Some((id, _, true)) => go_to(app, id), - Some((id, true, _)) => { - app.run("select.toggle", json!({ "id": id })).ok(); - } - Some((id, false, _)) => { - app.run("select.set", json!({ "ids": [id] })).ok(); - } + Some((id, m, false)) => click(app, ui.ctx(), rows, id, m), None => {} } if o.info { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index f27a42b53..d81432c09 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -56,6 +56,27 @@ fn panel(app: &mut VectorcraftApp) -> String { painted_text(app, super::links::show) } +/// Click the row named `name` in the panel with `mods` held (frames at `time` and just after: a +/// second apart, two clicks are not a double-click). +fn click_row(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, mods: egui::Modifiers, time: f64) { + let frame = |app: &mut VectorcraftApp, events: Vec, time: f64| { + let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(); + super::tests_appearance::frame_raw(ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show) + }; + let at = super::tests_appearance::text_rect(&frame(app, vec![], time), name).center(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: mods }; + for (i, e) in [egui::Event::PointerMoved(at), button(true), button(false)].into_iter().enumerate() { + frame(app, vec![e], time + 0.01 * (i + 1) as f64); + } +} + +/// The selected objects' ids, in ascending order. +fn selected(app: &VectorcraftApp) -> Vec { + let mut ids: Vec = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect(); + ids.sort_unstable(); + ids +} + #[test] fn the_panel_lists_the_images_with_link_info_for_the_selected_one() { let dir = Folder::new("panel"); @@ -75,6 +96,42 @@ fn the_panel_lists_the_images_with_link_info_for_the_selected_one() { } } +/// Shift-click selects the images of the rows from the last row clicked without Shift; Ctrl/Cmd-click +/// adds or removes one image, and Ctrl/Cmd-Shift-click adds the rows' images to the selection. +#[test] +fn shift_click_selects_a_range_of_links_and_ctrl_click_toggles_one() { + let dir = Folder::new("range"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for (i, name) in ["a.png", "b.png", "c.png", "d.png"].into_iter().enumerate() { + let path = dir.file(name, &png(20, 20)); + app.run("file.place", json!({"path": path, "at": [50 + 80 * i, 100]})).unwrap(); + } + let list = app.run("links.list", json!({})).unwrap(); + let rows: Vec<(String, u64)> = + list["links"].as_array().unwrap().iter().map(|r| (r["name"].as_str().unwrap().to_string(), r["id"].as_u64().unwrap())).collect(); + assert_eq!(rows.len(), 4, "{list}"); + let id = |rows_at: &[usize]| { + let mut ids: Vec = rows_at.iter().map(|&i| rows[i].1).collect(); + ids.sort_unstable(); + ids + }; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + click_row(&mut app, &ctx, &rows[0].0, egui::Modifiers::NONE, 1.0); + assert_eq!(selected(&app), id(&[0])); + click_row(&mut app, &ctx, &rows[2].0, egui::Modifiers::SHIFT, 2.0); + assert_eq!(selected(&app), id(&[0, 1, 2]), "Shift-click selects the rows from the one clicked before"); + click_row(&mut app, &ctx, &rows[1].0, egui::Modifiers::COMMAND, 3.0); + assert_eq!(selected(&app), id(&[0, 2]), "Ctrl/Cmd-click removes one image"); + click_row(&mut app, &ctx, &rows[3].0, egui::Modifiers::SHIFT | egui::Modifiers::COMMAND, 4.0); + assert_eq!(selected(&app), id(&[0, 1, 2, 3]), "Ctrl/Cmd-Shift-click adds the rows from the one clicked before"); + click_row(&mut app, &ctx, &rows[0].0, egui::Modifiers::SHIFT, 5.0); + assert_eq!(selected(&app), id(&[0, 1]), "Shift-click replaces the selection with the rows"); + click_row(&mut app, &ctx, &rows[3].0, egui::Modifiers::NONE, 6.0); + assert_eq!(selected(&app), id(&[3])); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6515232eb..dbde4ced8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -621,6 +621,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. + - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). - A `.ai` or `.ait` file copied in a file manager pastes as its art: one that holds a PDF pastes as a copied `.pdf` file does, and one saved as PostScript pastes as a copied `.eps` file does. From 951f7c28421f40aa8faffb7f7711f26ed9d0bb76 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:12:59 -0700 Subject: [PATCH 035/184] =?UTF-8?q?View:=20in=20Presentation=20Mode=20no?= =?UTF-8?q?=20key=20but=20=E2=86=90,=20=E2=86=92=20and=20Escape=20acts,=20?= =?UTF-8?q?and=20no=20context=20menu=20opens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Presentation Mode the canvas showed the active tool's cursor, a right click selected the object under the pointer and opened the context menu, and every key other than the arrows and Escape worked as in Normal Screen Mode: tool keys, Delete, Enter, Tab, F and the command shortcuts. A two-finger tap undid. With a tool that claims Escape, such as the Intertwine tool, Escape did not leave the mode. In the mode only the Escape, Left Arrow and Right Arrow keys now act, and Escape leaves the mode whatever the tool. Every other key press, the text it types and the clipboard keys are dropped before any shortcut reads them, the macOS menu bar's key equivalents included. A right click or a long touch opens no context menu, a two- or three-finger tap undoes or redoes nothing, and the pointer over the canvas is the default one. F and Shift+F no longer leave the mode; Escape does. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/canvas.rs | 15 ++++- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/shortcuts.rs | 73 ++++++++++++++-------- crates/ui-egui/src/tests_screenmode.rs | 85 ++++++++++++++++++++++---- docs/target-app-parity.md | 3 +- 5 files changed, 135 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2870d1d51..d098dc98c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -236,7 +236,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let xf = Xf::new(rect, &v); let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None }; panel_drop(app, ui, &resp, &xf); - context_menu(app, &resp, &xf); + // In Presentation Mode a right click or a long touch opens no context menu. + if app.ui.screen_mode < 3 { + context_menu(app, &resp, &xf); + } let painter = ui.painter_at(rect); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -536,7 +539,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else { None }; - let cur = if let Some(c) = view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info))) { + // In Presentation Mode the pointer is the default one, with no tool cursor. + let tool_cursor = + (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info)))); + let cur = if let Some(c) = tool_cursor.flatten() { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); // The loaded place cursor carries the file's thumbnail. if app.session.tool_id() == "place" @@ -1210,7 +1216,10 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec } } ui.data_mut(|d| d.insert_temp(key, taps)); - if let Some(cmd) = tap { + // In Presentation Mode a tap of two or three fingers undoes or redoes nothing. + if let Some(cmd) = tap + && app.ui.screen_mode < 3 + { crate::menus::invoke(app, cmd, serde_json::json!({})); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6b322a891..225cfb0cc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1242,8 +1242,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { Some(m) => m.min(3) as u8, - // F cycles the three screen modes; from Presentation Mode (not in the cycle) it - // goes back to Normal. + // F cycles the three screen modes. Without a mode, the command also goes from + // Presentation Mode (not in the cycle) back to Normal. None if app.ui.screen_mode >= 2 => 0, None => app.ui.screen_mode + 1, }; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e1a844550..f258aeaf2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -263,6 +263,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } return; } + if app.ui.screen_mode == 3 { + presentation_keys(app, ctx, escape_pair); + return; + } let typing = ctx.egui_wants_keyboard_input(); // Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already). if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { @@ -287,10 +291,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); } if k == Key::Escape && !busy && !claimed { - // Escape leaves Presentation Mode first: it hides the menus and panels. - if app.ui.screen_mode == 3 { - let _ = app.run("view.presentation", json!({})); - } else if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { + if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { let _ = app.run("object.pattern.done", json!({})); } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { let _ = app.run("object.exitIsolation", json!({})); @@ -332,28 +333,6 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if ctx.input(|i| i.key_pressed(Key::Tab)) { ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } - // In Presentation Mode, ← and → go to the previous and the next artboard. Every arrow key press - // is removed from the input here, so no arrow key moves the selection or is sent to the tool. - if app.ui.screen_mode == 3 { - let mut steps = vec![]; - ctx.input_mut(|i| { - i.events.retain(|e| match e { - egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { - steps.push(false); - false - } - egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { - steps.push(true); - false - } - egui::Event::Key { key: Key::ArrowUp | Key::ArrowDown, pressed: true, .. } => false, - _ => true, - }) - }); - for next in steps { - presentation_step(app, next); - } - } // Type tool editing: text, IME composition and editing keys go to the tool. if app.session.tool_wants_text() { type_text(app, ctx); @@ -637,6 +616,48 @@ fn escape_resets_rotation(app: &VectorcraftApp, ctx: &egui::Context, second: boo && !crate::canvas::ruler_origin_dragging(ctx) } +/// The keys in Presentation Mode, where nothing is edited: Escape leaves the mode, and ← and → go to +/// the previous and the next artboard ([`presentation_step`]). Every other key press, the text it +/// types and the clipboard keys are removed from the input, so no shortcut, tool or field acts on +/// them. The macOS menu bar's key equivalents arrive as key presses too ([`crate::native_menu`]). +fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: Option) { + let (mut leave, mut steps) = (false, vec![]); + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::Key { key: Key::Escape, pressed: true, .. } => { + leave = true; + false + } + egui::Event::Key { key: Key::ArrowLeft, pressed: true, .. } => { + steps.push(false); + false + } + egui::Event::Key { key: Key::ArrowRight, pressed: true, .. } => { + steps.push(true); + false + } + egui::Event::Key { pressed: true, .. } + | egui::Event::Text(_) + | egui::Event::Ime(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Paste(_) => false, + _ => true, + }) + }); + for next in steps { + presentation_step(app, next); + } + if leave { + let _ = app.run("view.presentation", json!({})); + // The Escape that left the mode can start a pair, as in `handle`. + if escape_pair.is_some() && !egui::Popup::is_any_open(ctx) { + let now = ctx.input(|i| i.time); + ctx.data_mut(|d| d.insert_temp(escape_pair_id(), now)); + } + } +} + /// Go to the next artboard (`next`) or the previous one with `view.goToArtboard`, as the status bar /// navigator's Next and Previous buttons do, for the arrow keys and a click in Presentation Mode. /// On the last artboard a step forward leaves the view as it is, and so does a step back on the diff --git a/crates/ui-egui/src/tests_screenmode.rs b/crates/ui-egui/src/tests_screenmode.rs index 6c530247e..a5f5e438b 100644 --- a/crates/ui-egui/src/tests_screenmode.rs +++ b/crates/ui-egui/src/tests_screenmode.rs @@ -1,6 +1,7 @@ //! Screen modes and Presentation Mode (#472): Escape leaves Presentation Mode, F cycles the three //! screen modes, and no screen mode is saved with the preferences. In Presentation Mode, ← and → -//! and a click go between the artboards. +//! and a click go between the artboards, no other key acts, a right click opens no context menu and +//! the pointer is the default one. use serde_json::json; use vectorcraft_engine::Session; @@ -8,7 +9,7 @@ use vectorcraft_engine::Session; use crate::VectorcraftApp; /// One headless frame of the whole window with `events`. -fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { @@ -16,6 +17,7 @@ fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec app.ui(ui); }); out.textures_delta.clear(); + out } fn escape() -> Vec { @@ -119,8 +121,8 @@ fn arrow_keys_go_between_artboards_in_presentation_mode() { /// Next does, and the tool gets no press: a click on the rectangle with the Selection tool selects /// nothing, and with the Rectangle tool a drag draws nothing and a click pressed and released in /// one frame opens no dialog. A drag, also with the middle button or with Space held, leaves the -/// view as it is, the press that closes the context menu goes to no other artboard, and on the last -/// artboard a click leaves the view as it is. +/// view as it is, a right click opens no context menu and goes to no other artboard, and on the +/// last artboard a click leaves the view as it is. #[test] fn a_click_goes_to_the_next_artboard_in_presentation_mode() { let ctx = egui::Context::default(); @@ -136,10 +138,8 @@ fn a_click_goes_to_the_next_artboard_in_presentation_mode() { for e in [vec![right(true)], vec![right(false)], vec![]] { frame(&mut app, &ctx, e); } - assert!(egui::Popup::is_any_open(&ctx), "a right click opens the context menu"); - click(&mut app, &ctx, middle - egui::vec2(150.0, 0.0)); - assert!(!egui::Popup::is_any_open(&ctx), "a click closes it"); - assert_eq!(view(&app).0, 1, "the click that closes the context menu"); + assert!(!egui::Popup::is_any_open(&ctx), "a right click opens no context menu"); + assert_eq!(view(&app).0, 1, "a right click"); app.select_tool("rectangle"); let button = |button, pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; let to = middle + egui::vec2(80.0, 60.0); @@ -171,8 +171,8 @@ fn a_click_goes_to_the_next_artboard_in_presentation_mode() { assert!(unchanged(&app), "nothing was selected or drawn"); } -/// In Presentation Mode a tap goes to the next artboard. A tap of two fingers undoes (Enable Touch -/// Gestures) and goes to no other artboard. +/// In Presentation Mode a tap goes to the next artboard. A tap of two fingers, which undoes in the +/// other screen modes (Enable Touch Gestures), undoes nothing and goes to no other artboard. #[test] fn a_tap_goes_to_the_next_artboard_in_presentation_mode() { let ctx = egui::Context::default(); @@ -192,16 +192,75 @@ fn a_tap_goes_to_the_next_artboard_in_presentation_mode() { tap(&mut app, 1); assert_eq!((view(&app).0, objects(&app)), (1, 1), "a tap"); tap(&mut app, 2); - assert_eq!((view(&app).0, objects(&app)), (1, 0), "two fingers undo the rectangle"); + assert_eq!((view(&app).0, objects(&app)), (1, 1), "two fingers undo nothing"); +} + +/// In Presentation Mode no key but ←, → and Escape acts: Cmd/Ctrl+A selects nothing, P and V pick +/// no tool, Delete and Backspace delete nothing, Cmd/Ctrl+Z undoes nothing, Enter opens no dialog, +/// Tab hides no panel and F keeps the screen mode. Escape still leaves the mode, also with a tool +/// that claims Escape. +#[test] +fn no_other_key_works_in_presentation_mode() { + use egui::{Key, Modifiers}; + let ctx = egui::Context::default(); + let mut app = presenting(&ctx); + // A press and release of `key` with `modifiers` held, and the text it types. + let press = |key, modifiers: Modifiers, text: Option<&str>| { + let k = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let mut events = vec![egui::Event::ModifiersChanged(modifiers), k(true)]; + events.extend(text.map(|t| egui::Event::Text(t.into()))); + events.extend([k(false), egui::Event::ModifiersChanged(Modifiers::NONE)]); + events + }; + app.run("select.none", json!({})).unwrap(); + frame(&mut app, &ctx, press(Key::A, Modifiers::COMMAND, None)); + assert!(app.session.active().unwrap().selection.objects.is_empty(), "Cmd/Ctrl+A selected the rectangle"); + app.run("select.all", json!({})).unwrap(); + let unchanged = snapshot(&app); + let panels = (app.ui.dock, app.ui.toolbar); + for (what, events) in [ + ("Delete", press(Key::Delete, Modifiers::NONE, None)), + ("Backspace", press(Key::Backspace, Modifiers::NONE, None)), + ("Cmd/Ctrl+Z", press(Key::Z, Modifiers::COMMAND, None)), + ("Enter", press(Key::Enter, Modifiers::NONE, None)), + ("Tab", press(Key::Tab, Modifiers::NONE, None)), + ("F", press(Key::F, Modifiers::NONE, Some("f"))), + ("P", press(Key::P, Modifiers::NONE, Some("p"))), + ] { + frame(&mut app, &ctx, events); + assert!(unchanged(&app), "{what} changed the document, the selection or the undo steps"); + let state = (app.session.tool_id(), app.ui.dialog.is_none(), app.ui.screen_mode, (app.ui.dock, app.ui.toolbar)); + assert_eq!(state, ("selection", true, 3, panels), "{what}"); + } + app.select_tool("rectangle"); + frame(&mut app, &ctx, press(Key::V, Modifiers::NONE, Some("v"))); + assert_eq!(app.session.tool_id(), "rectangle", "V"); + // The Intertwine tool claims Escape in the other screen modes. + app.select_tool("intertwine"); + frame(&mut app, &ctx, escape()); + assert_eq!(app.ui.screen_mode, 0, "Escape leaves Presentation Mode"); +} + +/// In Presentation Mode the pointer over the canvas is the system's default pointer, not the +/// tool's cursor. +#[test] +fn the_pointer_is_the_default_one_in_presentation_mode() { + let ctx = egui::Context::default(); + let mut app = presenting(&ctx); + app.select_tool("rectangle"); + let middle = app.canvas_rect.unwrap().center(); + let out = frame(&mut app, &ctx, vec![egui::Event::PointerMoved(middle)]); + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default); + assert!(out.platform_output.cursor_image.is_none(), "no tool cursor bitmap"); } #[test] -fn f_cycles_the_screen_modes_and_leaves_presentation_mode() { +fn f_cycles_the_screen_modes_and_the_command_leaves_presentation_mode() { let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); let modes: Vec = (0..4).map(|_| app.run("view.screenMode", json!({})).unwrap().as_u64().unwrap()).collect(); assert_eq!(modes, [1, 2, 0, 1]); app.run("view.presentation", json!({})).unwrap(); - assert_eq!(app.run("view.screenMode", json!({})).unwrap(), json!(0), "from Presentation Mode, F goes back to Normal"); + assert_eq!(app.run("view.screenMode", json!({})).unwrap(), json!(0), "from Presentation Mode, view.screenMode goes back to Normal"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dbde4ced8..cb59e74b0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -519,8 +519,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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 or its centre is nearest a gridline on each axis, #740; 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). - **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls. - **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows. -- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). +- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). In Presentation Mode, → and ← go to the next and the previous artboard, and a click anywhere goes to the next one, as the status bar navigator's Next and Previous do (`view.goToArtboard`): on the last artboard → and a click leave the view as it is, and so does ← on the first. The arrow keys do not move the selection there, and the tool does not receive a click, so a click selects and draws nothing (behavior reference: [View artwork](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). + In Presentation Mode no key press other than ←, → and Escape runs a command or reaches the tool (tool keys, Delete, Enter, Tab, F, Shift+F and every command shortcut, the macOS menu bar's key equivalents included), a right click or a long touch opens no context menu, a tap of two or three fingers undoes or redoes nothing, and the pointer over the canvas is the default one instead of the tool's cursor. Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. Choosing the Artboard tool (Shift+O, its toolbar button, or Edit Artboards in Properties or Document Setup) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it. In the Artboards panel, a click on the active artboard's options icon, or a double-click on another row's icon, opens Artboard Options for that row's artboard, and the panel menu's Artboard Options… opens it for the active artboard (part of #1876). Both used to choose the Artboard tool instead. Opened while another tool is in use, the dialog moves the art with a new X or Y when the Artboard tool's Move Artwork with Artboard option is on. From 2e9870b1d049952e58195090b07f992c8915b25d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:23:19 -0700 Subject: [PATCH 036/184] Rotate View: a Space+Shift drag rotates the view from any tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag with Space and Shift held panned the view, as a Space drag does. With Space and Shift held and Cmd up, a drag on the canvas now turns the view about the canvas center, as a Rotate View tool drag does, whatever the tool. Shift does not round the angle to 15° as it does with the Rotate View tool, and the drag keeps turning the view when Shift is let go. Space alone still pans, and Cmd+Space still zooms with or without Shift. The pointer shows the rotate cursor while Space and Shift are held without Cmd, and during the drag. The Rotate View tool also shows it now, where it showed the arrow. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/canvas.rs | 108 ++++++++++++++++++++++++++++++++--- docs/target-app-parity.md | 1 + 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d098dc98c..381148f59 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -82,9 +82,12 @@ enum Drag { held: f32, moved: bool, }, + /// A Rotate View tool drag, or a Space+Shift drag with any tool. With `snaps` (the Rotate + /// View tool), Shift rounds the angle to 15°. RotateView { start_angle: f64, start_rot: f64, + snaps: bool, }, /// A Selection tool move dragged off the canvas: the panels get the art /// ([`widgets::PanelDrag::Art`]). @@ -526,12 +529,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if resp.hovered() { let m = ui.input(|i| i.modifiers); let space = ui.input(|i| i.key_down(egui::Key::Space)); - let panning = matches!(ui.data(|d| d.get_temp::(drag_id())), Some(Drag::Pan { .. })); + let drag = ui.data(|d| d.get_temp::(drag_id())); + let panning = matches!(drag, Some(Drag::Pan { .. })); let zooming = if space { m.command } else { app.session.tool_id() == "zoom" }; + // A rotating drag keeps its cursor when Shift is let go. + let rotating = + matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" }; // The hand and the magnifier are drawn like the tool cursors below: Windows has no // system cursor for them (winit shows the four-headed move cursor for Grab). let view_cursor = if panning { Some(Cursor::HandGrab) + } else if rotating { + Some(Cursor::Rotate) } else if zooming { Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }) } else if space || app.session.tool_id() == "hand" { @@ -735,10 +744,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } let tool = app.session.tool_id(); - // Held, Space is the Hand tool for the moment, and Cmd+Space the Zoom tool (with Alt, zooming - // out), whatever the tool. + // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and + // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. let zoom_mode = if space { m.command } else { tool == "zoom" }; - let pan_mode = if space { !m.command } else { tool == "hand" }; + let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" }; + let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" }; let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. })); // egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a // press on the canvas itself reaches the tools, else it would land at the canvas's centre. @@ -761,9 +771,9 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } } else if pan_mode { Drag::Pan { start: p, center: v.center, middle: false } - } else if tool == "rotateView" { + } else if rotate_mode { let c = rect.center(); - Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation } + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } } else { // Cmd with another tool drags with the selection tool used last (the session lends it). let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; @@ -795,11 +805,11 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: vm.center = center - d; } } - Drag::RotateView { start_angle, start_rot } => { + Drag::RotateView { start_angle, start_rot, snaps } => { let c = rect.center(); let a = (p.y - c.y).atan2(p.x - c.x) as f64; let mut deg = start_rot + (a - start_angle).to_degrees(); - if m.shift { + if snaps && m.shift { deg = (deg / 15.0).round() * 15.0; } if let Some(vm) = app.view_mut() { @@ -5117,6 +5127,88 @@ mod tests { assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}"); } + #[test] + fn space_shift_drag_rotates_the_view_whatever_the_tool() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + // `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`. + let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| { + let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] }; + frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat()); + frame(app, &ctx, vec![button(from, true, held)]); + frame(app, &ctx, vec![Event::PointerMoved(to)]); + frame(app, &ctx, vec![button(to, false, held)]); + frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat()); + }; + // `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right. + let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin()); + let before = *app.view().unwrap(); + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0)); + let after = *app.view().unwrap(); + assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}"); + assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center"); + assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing"); + // Shift does not round the angle: 90° and 20° more make 110°, not 105°. + drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view()); + // Space alone still pans, and Cmd+Shift+Space still zooms. + let v = *app.view().unwrap(); + drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0)); + let panned = *app.view().unwrap(); + assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom)); + assert_ne!(panned.center, v.center, "Space pans"); + drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c); + assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in"); + assert_eq!(app.view().unwrap().rotation, v.rotation); + // The Rotate View tool's Shift still rounds to 15°. + app.select_tool("rotateView"); + app.view_mut().unwrap().rotation = 0.0; + drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0)); + assert_eq!(app.view().unwrap().rotation, 15.0); + drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0)); + assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view()); + } + + #[test] + fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() { + use egui::{CursorIcon, Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let c = app.canvas_rect.unwrap().center(); + // `shown` returns the system cursor the canvas sets over a window without the focus. On macOS + // the window paints the glyph only with the focus, and on the other desktop builds (not + // Wayland, which the tests do not set) the window sets the system cursor next to the bitmap. + let shown = |app: &mut VectorcraftApp, events: Vec| { + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.cursor_icon + }; + let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }; + assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias); + // Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand. + shown(&mut app, vec![button(c, true)]); + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias); + assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab); + // Cmd+Shift+Space zooms. + assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn); + assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias); + app.select_tool("rotateView"); + assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool"); + } + #[test] fn space_pressed_mid_drag_moves_what_the_tool_draws() { use egui::{Event, Modifiers}; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cb59e74b0..9901ddb37 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -502,6 +502,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked. - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it. In the Swatches panel, Alt-dragging a swatch onto another swatch of its kind (not a tint swatch) replaces that swatch's color, gradient or pattern, and the swatch keeps its name and place. From 5eea7e4f66dff4a99e343c305580a505ab076241 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 18:47:57 -0700 Subject: [PATCH 037/184] Appearance: the inline effect editor's text fields keep what is typed The Appearance panel's inline effect editor drew each text parameter (such as Drop Shadow's color and Revolve's edge) as a text field over a copy of the stored value made again in every frame. A character typed in one frame was gone in the next, so the field changed a value only when its new text and the Enter press arrived in the same frame, as with a paste. The field is now the panels' shared exact_text_field, which keeps the text typed while it has the focus, as the number fields beside it do. Enter or a click elsewhere sends the value with effect.setParams as one undo step, and Escape drops it. --- crates/ui-egui/src/panels/appearance.rs | 72 +++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index bb10b1ee1..a6b5a9d62 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -782,10 +782,8 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: widgets::dim_label(ui, &size.map_or_else(String::new, |(w, h)| format!("{w} × {h} px"))); } Value::String(s) => { - let mut buf = s.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(90.0)); - if r.lost_focus() && buf != *s { - change = Some((key.clone(), json!(buf))); + if let Some(typed) = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), s, 90.0) { + change = Some((key.clone(), json!(typed))); } } // Free Distort's corners are dragged in its dialog. @@ -1059,6 +1057,72 @@ mod tests { assert_eq!(editor_params(&e).get("angle"), Some(&json!(270))); } + /// A text field of the inline editor keeps what is typed from one frame to the next: a Drop + /// Shadow's color typed a character a frame and confirmed with Enter is set in one undo step. + /// Escape drops what is typed. + #[test] + fn the_effect_editor_keeps_text_typed_across_frames() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, "ap-fx-open-None-0", true); + let mut time = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out + }; + // Every text drawn, with its center. + fn texts(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let drawn = |out: &egui::FullOutput| { + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + }; + run(&mut app, vec![]); + let shown = drawn(&run(&mut app, vec![])); + let at = shown.iter().find(|(t, _)| t == "#000000").map(|(_, p)| *p); + let Some(at) = at else { panic!("no color field: {shown:?}") }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + run(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + let undo = app.session.active().unwrap().history.undo.len(); + for ch in "#ff0000".chars() { + run(&mut app, vec![egui::Event::Text(ch.to_string())]); + } + let shown = drawn(&run(&mut app, vec![])); + assert!(shown.iter().any(|(t, _)| t == "#ff0000"), "the field shows what was typed: {shown:?}"); + run(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + run(&mut app, vec![]); + let color = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["color"].clone(); + assert_eq!(color(&app), json!("#ff0000")); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Escape drops what is typed. + for events in [vec![button(true)], vec![button(false)]] { + run(&mut app, events); + } + run(&mut app, vec![egui::Event::Text("0".into())]); + let shown = drawn(&run(&mut app, vec![])); + assert!(shown.iter().any(|(t, _)| t.len() == 8 && t.starts_with('#')), "a 0 typed into the color: {shown:?}"); + run(&mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]); + run(&mut app, vec![]); + assert_eq!(color(&app), json!("#ff0000")); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + } + #[test] fn drops_pick_the_command() { use vectorcraft_doc::{Appearance, FillLayer}; From 1e3886c053a999c547b395351a7d5577ae51ddb2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:03:14 -0700 Subject: [PATCH 038/184] Document Raster Effects Settings: a file's resolution outside 1 to 2400 ppi opens in range document.rasterEffectsSettings accepts a resolution from 1 to 2,400 ppi, but a file's raster_effects_ppi opened as it was saved. With 0, -5 or 5000 in the file, object.rasterize without a ppi param failed with "ppi must be between 1 and 2400", and document.rasterEffectsSettings and document.info reported the stored value. With 0 or -5, EPS and PDF export wrote a freeform gradient as a 1 x 1 px image, and EMF export wrote every gradient as one. A saved New Document preset in the preferences kept such a value too. A file's or a saved preset's resolution outside 1 to 2,400 ppi is now read as the nearer end of that range. Documents with a resolution in range open as before. Docs: docs/mcp.md (resolution, rasterEffectsPpi). --- crates/engine/src/cmd/newdoc.rs | 7 ++++ crates/engine/src/tests_newdoc.rs | 11 ++++++ crates/engine/src/tests_rastersettings.rs | 45 +++++++++++++++++++++++ crates/format/src/lib.rs | 2 + crates/format/tests/prop_format.rs | 2 +- docs/mcp.md | 6 ++- 6 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index cfec89b6a..d6c104076 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -103,6 +103,11 @@ impl PreviewMode { } } +/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). +fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)) +} + /// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -125,6 +130,8 @@ pub struct DocSettings { pub bleed: [f64; 4], pub background: Background, pub color_mode: ColorMode, + /// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end). + #[serde(deserialize_with = "de_ppi")] pub raster_effects_ppi: f64, pub preview_mode: PreviewMode, } diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs index d84859b88..380accfe5 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests_newdoc.rs @@ -225,6 +225,17 @@ fn saved_presets_live_in_the_preferences() { assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err()); } +#[test] +fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() { + let mut s = Session::new(); + let saved = json!({"newDocPresets": [{"name": "Zero", "rasterEffectsPpi": 0}, {"name": "Huge", "rasterEffectsPpi": 5000}]}); + s.apply_prefs(serde_json::from_value(saved).unwrap()); + let ppi: Vec> = presets(&mut s, "Saved").iter().map(|p| p["rasterEffectsPpi"].as_f64()).collect(); + assert_eq!(ppi, [Some(1.0), Some(2400.0)]); + s.execute("file.new", &json!({"preset": "Zero"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0); +} + #[test] fn rearrange_keeps_its_grid() { let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); diff --git a/crates/engine/src/tests_rastersettings.rs b/crates/engine/src/tests_rastersettings.rs index b0d20753b..616a6bf55 100644 --- a/crates/engine/src/tests_rastersettings.rs +++ b/crates/engine/src/tests_rastersettings.rs @@ -213,3 +213,48 @@ fn raster_settings_round_trip_and_old_files_load() { let back = vectorcraft_format::load(&bytes).unwrap(); assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default())); } + +/// Save the active document with the raster effects resolution `ppi` and open the file. +fn reopen_with_ppi(s: &mut Session, ppi: f64) { + let mut d = (*s.doc().unwrap().doc).clone(); + d.raster_effects_ppi = ppi; + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d)); + s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap(); +} + +#[test] +fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() { + for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] { + let mut s = session(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap()); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + reopen_with_ppi(&mut s, saved); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}"); + assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened); + assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened); + // Rasterize without a ppi param renders the 10 pt square at it. + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let out = s.execute("object.rasterize", &json!({})).unwrap(); + assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}"); + } +} + +#[test] +fn a_gradient_exports_at_the_resolution_the_file_opens_with() { + for saved in [0.0, -5.0] { + let mut s = session(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap()); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap(); + reopen_with_ppi(&mut s, saved); + // EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi. + let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0); + let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap(); + let mut sizes = vec![]; + vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + sizes.push((im.width, im.height)); + } + }); + assert_eq!(sizes, [(10, 10)], "saved as {saved}"); + } +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d37b07685..713984b73 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -555,6 +555,8 @@ pub fn load_file(bytes: &[u8]) -> Result { doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND); let mut depth_notes = vec![]; doc.limit_depth_noting(&mut depth_notes); + // Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). + doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 7ec1806ff..66b08b264 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -225,7 +225,7 @@ fn approx_equal_documents_compare_equal() { fn bug_f64_bit_exact_roundtrip() { check_native_roundtrip_exact(&rich_doc()).unwrap(); let mut d = Document::new(126.86291501015239, 214.69463130731182); - d.raster_effects_ppi = 0.1 + 0.2; + d.raster_effects_ppi = (0.1 + 0.2) * 1000.0; check_native_roundtrip_exact(&d).unwrap(); } diff --git a/docs/mcp.md b/docs/mcp.md index bee14e10e..465a4f704 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2220,7 +2220,8 @@ art). Pass any of these keys to change them in one undo step; `document.setUnits `file.new` takes everything New Document sets: `preset` (a name from `file.newPresets`), `name`, `width`/`height` (points, or lengths such as `"210 mm"`), `units`, `orientation`, `artboards` with `artboardLayout {layout, columns, -spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` and `previewMode`. +spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` (1 to 2,400; a saved preset's +value outside that range is read as the nearer end) and `previewMode`. `file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets @@ -2342,7 +2343,8 @@ written unless the whole batch succeeds puts the saves and exports last, or runs ## Document Raster Effects Settings `document.rasterEffectsSettings` holds how raster effects (shadows, glows, blurs, feathers) become images when PDF -export or Expand Appearance renders them: `resolution` (ppi, also New Document's `rasterEffectsPpi`), `colorModel` +export or Expand Appearance renders them: `resolution` (1 to 2,400 ppi, also New Document's `rasterEffectsPpi`; a +file's value outside that range opens as the nearer end), `colorModel` (the document's `rgb`/`cmyk`, `grayscale` or `bitmap`), `background` (`white` makes the images opaque), `antiAlias` (off: hard edges), `clippingMask` (the white stays under the art only), `addAround` (0 to 1,000 points of room around the art; a file's value outside that range opens as the nearer end) and `preserveSpotColors` (stored). They are From 92647fa5b626f07ab63a842496d4fa81c3c66953 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:43:33 +0000 Subject: [PATCH 039/184] Quote graph data CSV labels so they round-trip through graph.setData Found while testing crates/doc/src/graph.rs:429 (GraphSpec::to_csv) and crates/engine/src/cmd/graph.rs:85 (parse_csv) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/graph.rs | 19 ++++++---- crates/engine/src/cmd/graph.rs | 66 ++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 37329ec62..9e1df35d0 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -430,17 +430,22 @@ impl GraphSpec { let cells = self.cells(); let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len()); let mut out = String::from(","); - out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::>().join(",")); + // Quote a label holding a separator, quote or line break (inner quotes doubled), or one with + // surrounding spaces; `always` also quotes blank and numeric labels so they stay labels. + let field = |c: &str, always: bool| { + let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim(); + if special || (always && (c.trim().is_empty() || c.trim().parse::().is_ok())) { + format!("\"{}\"", c.replace('"', "\"\"")) + } else { + c.to_string() + } + }; + out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::>().join(",")); out.push('\n'); let labelled = !self.categories.is_empty(); for (i, r) in cells.iter().enumerate() { if labelled { - let c = self.categories.get(i).map(String::as_str).unwrap_or_default(); - if c.trim().is_empty() || c.trim().parse::().is_ok() || c != c.trim() { - out.push_str(&format!("\"{c}\"")); - } else { - out.push_str(c); - } + out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true)); out.push(','); } out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::>().join(",")); diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 8ade093e8..d326e3dba 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -82,20 +82,42 @@ impl Cell { } } +/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside). +fn split_unquoted(s: &str, sep: char) -> Vec<&str> { + let mut parts = vec![]; + let mut quoted = false; + let mut start = 0; + for (i, ch) in s.char_indices() { + if ch == '"' { + quoted = !quoted; + } else if ch == sep && !quoted { + parts.push(s.get(start..i).unwrap_or_default()); + start = i + ch.len_utf8(); + } + } + parts.push(s.get(start..).unwrap_or_default()); + parts +} + +/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed. +fn cell(c: &str) -> Cell { + let c = c.trim(); + match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) { + Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true }, + None => Cell { text: c.trim_matches('"').to_string(), quoted: false }, + } +} + fn parse_csv(csv: &str) -> (Vec, Vec, Vec>>) { - let rows: Vec> = csv - .lines() + // Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes. + let rows: Vec> = split_unquoted(csv, '\n') + .into_iter() .map(|l| l.trim_end_matches('\r')) .filter(|l| !l.trim().is_empty()) .map(|l| { - let sep = if l.contains('\t') { '\t' } else { ',' }; - l.split(sep) - .map(|c| { - let c = c.trim(); - let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"'); - Cell { text: c.trim_matches('"').to_string(), quoted } - }) - .collect() + let tabs = split_unquoted(l, '\t'); + let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') }; + parts.into_iter().map(cell).collect() }) .collect(); let Some(first) = rows.first() else { return (vec![], vec![], vec![]) }; @@ -2370,6 +2392,30 @@ mod tests { assert_eq!(cells, g.cells(), "{csv}"); } + #[test] + fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]); + let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]); + let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]); + let r = s + .execute( + "graph.create", + &json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}), + ) + .unwrap(); + let id = r["id"].as_u64().unwrap(); + let before = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(before["series"], series); + assert_eq!(before["categories"], categories); + s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap(); + let after = s.execute("graph.setData", &json!({"id": id})).unwrap(); + assert_eq!(after["series"], before["series"], "{}", before["csv"]); + assert_eq!(after["categories"], before["categories"], "{}", before["csv"]); + assert_eq!(after["rows"], before["rows"], "{}", before["csv"]); + } + #[test] fn empty_cells_are_blank_and_quoted_numbers_are_labels() { // Quoted years label the rows; empty and non-numeric cells are blank values, not zeros. From ee6158360607a1d7424c926fc38f61e0bbcd6d17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:42:09 -0700 Subject: [PATCH 040/184] Artboards: commands given an index that names no artboard fail instead of editing another one artboard.setProps and artboard.fitToSelection read an `index` that is negative, fractional, a string or null as 0 and edited the first artboard, and artboard.setProps edited the first artboard when the call named no `index`. artboard.duplicate, artboard.copy and artboard.cut took the Artboard tool's artboard for such an `index`. artboard.reorder failed for it with "missing index". These commands now read `index` as artboard.delete, artboard.move and artboard.fitToArt do, and fail with "no such artboard" when it names no artboard; the document and the clipboard stay as they were. artboard.setProps without `index` fails with "missing index". Without `index`, artboard.fitToSelection still fits the first artboard and the copy commands still take the Artboard tool's artboard. Docs: docs/mcp.md. --- crates/engine/src/cmd/layer.rs | 4 ++-- crates/engine/src/cmd/panelcmds.rs | 23 +++++++++++++++++------ crates/engine/src/tests_menucmds.rs | 25 +++++++++++++++++++++++++ crates/engine/src/tests_panelcmds.rs | 21 +++++++++++++++++++++ docs/mcp.md | 4 ++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 9d398d6a1..8ff970171 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -834,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result { } fn artboard_set(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?; let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; let now = rect_from(p, was); let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6; @@ -933,8 +933,8 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result { } fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; let st = s.doc()?; + let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? }; let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?; s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()})) } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 968444277..9b55ff8b1 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { fn artboard_reorder(s: &mut Session, p: &Value) -> Result { const C: &str = "artboard.reorder"; - let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize; + let i = artboard_index(&s.doc()?.doc, p, C)?; let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; s.edit("Reorder Artboards", |d, _| { if i >= d.artboards.len() { @@ -70,10 +70,21 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result { /// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy /// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option. -fn board_and_art(s: &Session, p: &Value) -> (usize, bool) { +/// An `index` that names no artboard is an error. +fn board_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(usize, bool)> { let t = s.tool_options_of("artboard"); - let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize; - (i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))) + let i = match p.get("index") { + Some(_) => artboard_index(&s.doc()?.doc, p, cmd)?, + None => t["active"].as_u64().unwrap_or(0) as usize, + }; + Ok((i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))) +} + +/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of +/// [`artboard_indices`] (`missing index`, `no such artboard`). +pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result { + let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?; + artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into())) } /// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden @@ -106,7 +117,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec } fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { - let (i, art) = board_and_art(s, p); + let (i, art) = board_and_art(s, p, "artboard.duplicate")?; let art = artboard_art(s, i, art)?; let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() }; let (index, ids) = s.edit("Duplicate Artboard", |d, _| { @@ -119,7 +130,7 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { /// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut /// then deletes them. pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result { - let (i, art) = board_and_art(s, p); + let (i, art) = board_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; let art = artboard_art(s, i, art)?; let st = s.doc()?; if cut && st.doc.artboards.len() <= 1 { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 22642ec0c..e05e5db99 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -736,6 +736,31 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() { assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default"); } +/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names +/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as +/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first +/// artboard. +#[test] +fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap(); + rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let (was, n) = (boards(&s), undo_len(&s)); + for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] { + for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] { + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + } + } + let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index"); + assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were"); + s.execute("artboard.fitToSelection", &json!({})).unwrap(); + assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default"); +} + // ---------- Edit Colors ---------- #[test] diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 8c62ffbd1..47066421a 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -130,6 +130,27 @@ fn duplicate_artboard_copies_its_art() { assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1); } +/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an +/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the +/// artboards, the art and the clipboard stay as they were. +#[test] +fn artboard_copies_refuse_an_index_that_names_no_artboard() { + let mut s = session(); + rect(&mut s); + s.execute("artboard.new", &json!({})).unwrap(); + s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap()); + let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len()); + for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] { + for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] { + let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) }; + assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}"); + } + } + let st = s.doc().unwrap(); + assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n)); + assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboard.is_none(), "nothing copied"); +} + /// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places /// it right of the last artboard, Paste in Place where it was, also in another document; each paste /// is one undo step, and the tool takes the pasted artboard. diff --git a/docs/mcp.md b/docs/mcp.md index 465a4f704..f77f4b2b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2287,6 +2287,10 @@ pastes only the art), in this document or another; the result's `artboard` is th does the same in one step → `{index, ids}`, and `artboard.move {copy: true, moveArt: true}` is the Artboard tool's Alt-drag. These two commands also move the pattern tiles of the copies when `patterns` is true (default: prefs `transformPatternTiles`); `edit.paste` leaves the tiles of a pasted artboard's art where they were. +`artboard.copy`, `artboard.cut` and `artboard.duplicate` take the Artboard tool's artboard when the call names no +`index`. Every artboard command that takes an `index` fails with `no such artboard` when the `index` is not the +0-based index of an artboard (past the last one, negative, fractional, a string or null), and the document and the +clipboard stay as they were. Scale Artwork with Artboard (#602): `artboard.setProps {index, x?, y?, width?, height?, scaleArt: true}` resizes an artboard and scales the art fully inside it, and its artboard guides, from the old rectangle onto the new one, each side From 27230233ccabf8cec2149d66cb0f5dab6baa40f3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:09:18 +0200 Subject: [PATCH 041/184] Effects: a Transform effect on a group paints each copy with its own gradient (#2451) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/group.rs | 28 ++++++++++++++++++++++++ crates/effects/src/reshape.rs | 40 +++++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 69425b907..2211a4364 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -491,4 +491,32 @@ mod tests { assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed"); } + + #[test] + fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; + let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; + for (k, paint) in paints.into_iter().enumerate() { + let square = + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0))); + let mut g = Node::group(NodeId(10), vec![square]); + let params = serde_json::json!({"moveH": 30, "copies": 2}); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(&g).unwrap(); + let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); + assert_eq!(copies.len(), 3); + for (i, c) in copies.iter().enumerate() { + // Each copy's own box: an unplaced gradient fits it, a placed one moved with it. + assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6); + match c.appearance.fill_paint() { + Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"), + Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"), + p => panic!("a gradient: {p:?}"), + } + } + } + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index a6e2926ac..ebfd22464 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -268,13 +268,21 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed)); for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; + // A gradient paints each copy by itself, not the one piece the copies make together. + if let Some((m, copies)) = transform + && copies > 0 + && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient()) + { + copy_with_gradients(leaf, &path, m, copies); + return; + } let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }); set_geometry(leaf, out, rule); }); - if e.id == "distort.transform" { - let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); + if let Some((m, copies)) = transform { transform_placed(art, m, copies); } } @@ -283,6 +291,34 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { } } +/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies` +/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each +/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather +/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would. +fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) { + let each = path.anchor_count(); + let mut anchors = each; + let mut placements: Vec> = vec![Arc::new(leaf.clone())]; + let mut xf = Affine::IDENTITY; + for _ in 0..copies { + anchors = anchors.saturating_add(each); + if anchors > crate::distort::MAX_COPY_ANCHORS { + break; + } + xf *= m; + let mut copy = leaf.clone(); + copy.transform(xf, vectorcraft_doc::Scaling::default()); + if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { + break; + } + placements.push(Arc::new(copy)); + } + // The copies become nodes of their own under a group with the object's id and name. + let mut g = Node::group(leaf.id, placements); + g.name = leaf.name.clone(); + *leaf = g; +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { From a6012d86e85329d0ed8ca35c45505c8c528ab229 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:13:56 +0200 Subject: [PATCH 042/184] Effects: a path with its own Transform effect paints each copy with its own gradient (#2451) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/lib.rs | 2 +- crates/effects/src/reshape.rs | 23 ++++++++++++++++++++++- crates/render/src/fx.rs | 4 +++- crates/render/src/tests_objectfx.rs | 21 +++++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89ba2d491..3f10343db 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RasterFx, outset, raster_effects}; -pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; +pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape}; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ebfd22464..fa59cae55 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -32,6 +32,17 @@ pub fn needs_outline(n: &Node) -> bool { ) } +/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient +/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy +/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do. +pub fn has_gradient_copies(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) + && (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) + && geometry_effects(n) + .iter() + .any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0) +} + /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a /// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke /// paints ([`outline_text`], which leaves inline graphics out). @@ -127,6 +138,16 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { a.transform(*xf, false); Node::group(n.id, vec![Arc::new(a)]) } + // The object as the one piece the copies of its Transform effect are made from. + _ if has_gradient_copies(n) => { + let mut one = n.clone(); + one.opacity = 1.0; + one.blend = Default::default(); + one.isolate = false; + one.extras_mut().mask = None; + one.appearance.effects.clear(); + Node::group(n.id, vec![Arc::new(one)]) + } _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), _ => return None, }; @@ -171,7 +192,7 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a /// symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) { + if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) { return None; } as_art(n, symbol_art) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 23352a280..f5281fb47 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -68,11 +68,13 @@ pub(crate) fn has_fx(n: &Node) -> bool { /// Does `n` carry visible object-level effects that only apply through its art (type, images, /// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with -/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), or is it a +/// a geometry effect on a fill or stroke of its own ([`effects::has_item_geometry`]), is it a path +/// whose Transform effect copies it with a gradient ([`effects::has_gradient_copies`]), or is it a /// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) || effects::has_item_geometry(n) + || effects::has_gradient_copies(n) || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 7a9add652..f4c72553f 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -697,3 +697,24 @@ fn a_strokes_own_glow_paints_around_that_stroke_only() { let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); assert!(worst <= 4, "max channel difference {worst}"); } + +#[test] +fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() { + use vectorcraft_color::GradientGeom; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(20.0, 50.0), aspect: 1.0, focal: None }; + // Fitted to the box on each render, and placed on the object. + for paint in [ramp.clone(), GradientPaint { geom: Some(geom), ..ramp }] { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 40.0, 20.0, 60.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(Paint::Gradient(Box::new(paint)), Paint::None, 0.0)); + n.appearance.effects = vec![fx("distort.transform", json!({"moveH": 40, "copies": 1}))]; + let img = render(&doc_with(Document::new(100.0, 100.0), n)); + let at = |x: usize| img.pixels.chunks(4).nth(50 * 100 + x).map(|p| (p[0], p[2])).unwrap_or_default(); + // The left edge of each copy is red, its right edge blue. + for (left, right) in [(1, 18), (41, 58)] { + assert!(at(left).0 > 200 && at(left).1 < 60, "{:?} at {left}", at(left)); + assert!(at(right).1 > 200 && at(right).0 < 60, "{:?} at {right}", at(right)); + } + } +} From b7018a337085288bc930f14f9a2419e58e6a7605 Mon Sep 17 00:00:00 2001 From: andeecollard Date: Fri, 16 Oct 2026 11:58:50 +0000 Subject: [PATCH 043/184] docs: quick Image Trace presets trace at once; the Image Trace button opens the controls The UI parity row (and G12 in gaps.md, M12 in roadmap.md) said quick Image Trace presets reveal the editable controls. Since baadbe2c6 a quick trace leaves the panel as it was (test a_quick_trace_leaves_the_panel_as_it_was) and the Image Trace button in the Control bar or Properties opens the controls. That matches the public documentation: choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or the Properties panel's Image Trace section (https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html). Reworded the three rows, cited the URL, added revision-history entries and status-line notes. Wording only; scores, statuses and hours unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB --- docs/gaps.md | 5 ++++- docs/roadmap.md | 5 +++-- docs/ui-parity.md | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 8cb39b77f..65d067ccf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -193,6 +193,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G12 Preferences and panels that don't act yet +- **Done:** quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the controls with Threshold and Advanced (Paths, Corners, Noise), as the public documentation describes (). Other context-specific Properties gaps remain. + - **Missing:** ~14 Preferences options stored but not acted on (#394: Open - **Done:** quick Image Trace presets reveal Threshold and Advanced controls (Paths, Corners, Noise); Properties can reopen them. Other context-specific Properties gaps remain. - **Missing:** ~15 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open @@ -331,6 +333,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | G1: Smart Guides line extension for a dragged end of a straight segment, at any angle (#2120); estimate unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1fdde8d36..6e0fe3b9a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -64,7 +64,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | 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; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (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`; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), 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 | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | -| 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; quick presets reveal adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; 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), 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 | 6–10 | +| 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; quick presets trace at once and the Image Trace button opens the adjustment controls; tiny fitted contours retain islands and holes; 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), 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 | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | @@ -84,6 +84,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Correction to M12: quick Image Trace presets trace at once; the Image Trace button opens the adjustment controls (). Wording only; status unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dfbd47ff0..9e17ae045 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -200,7 +200,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` | | Panels outside the app window (another monitor) | missing | single OS window | | Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; New/Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) | -| Quick Image Trace presets reveal editable controls; Properties reopens them | done | Threshold, Paths, Corners and Noise; `panels/image_trace.rs` headless interaction tests. No algorithm parity claim. | +| Quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the editable controls | done | Per the public documentation, choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or in Properties' Image Trace section (). The controls show Threshold, Paths, Corners and Noise with Advanced open; `panels/image_trace.rs` headless tests (`a_quick_trace_leaves_the_panel_as_it_was`, `sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source` for the button's route). No algorithm parity claim. | | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | | Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | | Properties panel per context (every selection type's Quick Actions) | partial | | @@ -234,6 +234,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Correction: quick Image Trace presets trace at once and leave the panel as it was (the 2026-10-14 row below said a quick preset reveals it; the later change in `baadbe2c6` made the button the way in); the Image Trace button in the Control bar or Properties opens the controls, as the public documentation describes. Wording only; scores and hours unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. | | 2026-10-16 | minor | Smart Guides: the dragged end of a straight segment snaps to the segment's extension at any angle, with the guide from its other end (#2120); scores and hours unchanged | From b28e8490c23a358bbefafe772544698498c4e7e0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 19:56:05 +0200 Subject: [PATCH 044/184] Effects: skip type tests when built without craft-fonts (#2476) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/bake.rs | 36 ++++++++++++++++++++++++++++++++++++ crates/effects/src/group.rs | 21 +++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 6697f5b65..e0165c2ef 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -572,6 +572,15 @@ mod tests { use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; use vectorcraft_geom::{Point, Rect, shapes}; + /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. + fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip + } + fn fx(id: &str, params: serde_json::Value) -> Effect { Effect { id: id.into(), params, visible: true } } @@ -647,6 +656,9 @@ mod tests { #[test] fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() { + if skip_without_craft_fonts() { + return; + } for (params, origins, square) in [ (json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)), (json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)), @@ -678,6 +690,9 @@ mod tests { #[test] fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() { + if skip_without_craft_fonts() { + return; + } let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); let mut hidden = type_node(55); hidden.visible = false; @@ -689,6 +704,9 @@ mod tests { #[test] fn type_outlined_next_to_type_that_stays_type_keeps_its_look() { + if skip_without_craft_fonts() { + return; + } let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); let mut shadowed = type_node(55); shadowed.opacity = 0.5; @@ -711,6 +729,9 @@ mod tests { #[test] fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() { + if skip_without_craft_fonts() { + return; + } let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]); // The clipping path is a group with a fill of its own, holding type with an effect of its own. let mut moved = type_node(60); @@ -729,6 +750,9 @@ mod tests { #[test] fn type_with_its_own_transform_effect_copies_stays_type() { + if skip_without_craft_fonts() { + return; + } let mut n = type_node(50); n.opacity = 0.5; n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))]; @@ -775,6 +799,9 @@ mod tests { #[test] fn type_whose_copies_paint_over_each_other_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2); // A 20 pt stroke on the characters reaches the copy below. @@ -790,6 +817,9 @@ mod tests { #[test] fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))]; let mut translucent = type_node(50); translucent.opacity = 0.5; @@ -862,6 +892,9 @@ mod tests { #[test] fn type_whose_copies_hold_too_much_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let spaced = |spaces: usize| { let mut n = type_node(50); if let NodeKind::Text(t) = &mut n.kind { @@ -899,6 +932,9 @@ mod tests { #[test] fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() { + if skip_without_craft_fonts() { + return; + } let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]); inner.id = NodeId(53); let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 78d51dca8..69425b907 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -293,6 +293,15 @@ mod tests { use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject}; use vectorcraft_geom::{Point, Rect, shapes}; + /// Type tests need glyph outlines, which only the optional craft-fonts build input provides. + fn skip_without_craft_fonts() -> bool { + let skip = vectorcraft_text::CRAFT_FONTS.is_empty(); + if skip { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + skip + } + fn sq(id: u64, x: f64, color: Color) -> Arc { Arc::new(Node::path( NodeId(id), @@ -350,6 +359,9 @@ mod tests { #[test] fn type_changes_the_result_of_every_pathfinder_effect() { + if skip_without_craft_fonts() { + return; + } let glyphs = crate::outline_text(&type_h()).unwrap(); let b = glyphs.geometric_bounds().unwrap(); assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}"); @@ -377,6 +389,9 @@ mod tests { #[test] fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() { + if skip_without_craft_fonts() { + return; + } let mut t = type_h(); (t.opacity, t.blend) = (0.5, BlendMode::Multiply); let look = |p: &Arc| (p.appearance.fill_paint(), p.opacity, p.blend); @@ -418,6 +433,9 @@ mod tests { #[test] fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() { + if skip_without_craft_fonts() { + return; + } use vectorcraft_color::BlendMode; let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply)); assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}"); @@ -447,6 +465,9 @@ mod tests { #[test] fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() { + if skip_without_craft_fonts() { + return; + } use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); From 6939a481a43b022263848a0cbe5aa88b1b5c2087 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:03:37 +0200 Subject: [PATCH 045/184] Dimension tool: Shift shows the constrained line while picking the second point (#2432) The Shift click already snapped the second point to 45-degree steps, but the dashed line from the first point followed the raw pointer, so the constraint looked absent until the click. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 18 ++++++++++++++++++ crates/tools/src/dimension.rs | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index f65a292c7..83e7be165 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -389,3 +389,21 @@ fn unknown_dimension_options_are_refused_by_commands_but_a_newer_file_still_open let doc = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert!(doc.node(NodeId(id)).unwrap().extras().dimension.is_some()); } + +/// With Shift down, the dashed line from the first picked point follows the 45-degree step the +/// second click will pick (#2432), not the raw pointer. +#[test] +fn shift_shows_the_constrained_line_while_picking_the_second_point() { + use vectorcraft_engine::tools::Overlay; + let mut s = session(); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + ev(&mut s, PointerKind::Down, 300.0, 300.0); + ev(&mut s, PointerKind::Up, 300.0, 300.0); + let end = |s: &mut Session, shift: bool| { + let e = PointerEvent::new(PointerKind::Move, 372.0, 310.0).with_mods(Mods { shift, ..Mods::default() }); + s.pointer(&e, ViewInfo::default()).unwrap(); + s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap() + }; + assert_eq!(end(&mut s, false).y, 310.0); + assert!((end(&mut s, true).y - 300.0).abs() < 1e-9); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index eb482a036..9fd871c70 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -60,9 +60,17 @@ pub struct DimensionTool { dragging: bool, hover: Option, at: Point, + /// Whether Shift was down at the last pointer event: the picked line shows constrained. + shift: bool, began: bool, } +/// `at` as a Shift click picks it: the direction from `last` snapped to 45° steps (from the +/// constrain angle), the distance kept. +fn constrained(cx: &ToolContext, last: Point, at: Point) -> Point { + last + vectorcraft_geom::constrain_angle_from(at - last, 45.0, cx.constrain_angle) +} + /// Nearest editable segment, with bounded work and no snapping to dimension artwork itself. pub fn pick(cx: &ToolContext, p: Point) -> Option { let mut best = None; @@ -217,7 +225,7 @@ impl DimensionTool { if mods.shift && let Some((last, _)) = self.points.last() { - at = *last + vectorcraft_geom::constrain_angle_from(at - *last, 45.0, cx.constrain_angle); + at = constrained(cx, *last, at); reference = reference.filter(|r| r.position(cx.doc) == Some(at)); } self.points.push((at, reference)); @@ -293,6 +301,7 @@ impl Tool for DimensionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.at = ev.pos; + self.shift = ev.mods.shift; match ev.kind { PointerKind::Move => { if self.pending.is_some() { @@ -393,7 +402,8 @@ impl Tool for DimensionTool { segment(edge, &mut out); } if let Some((p, _)) = self.points.last() { - out.push(Overlay::Line { a: *p, b: self.at, color, dashed: true }); + let b = if self.shift { constrained(cx, *p, self.at) } else { self.at }; + out.push(Overlay::Line { a: *p, b, color, dashed: true }); } out } From 101fd55fa009862a2c7f580fcc1202b1527db108 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:11:01 +0200 Subject: [PATCH 046/184] Type: a tab takes its width when area type breaks and justifies its lines (#2457) Line breaking, the line's natural width (and so justification) and the composer counted a tab as its glyph's width, a space, while it is set up to its next tab stop. A paragraph starting with a tab, as in the report, filled a justified line as if the tab were one space, and the line ran past the frame. break_line and the line width now measure tabs with tab_advance, and a paragraph with a tab is broken line by line, since the composer works with fixed glyph widths. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 37 +++++++++++++++++++++++++++++-------- crates/text/src/tests.rs | 15 +++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c56e0480d..0559730e2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1162,6 +1162,16 @@ fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, re ((at - rel).max(0.0), leader) } +/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the +/// tab origin: tabs take what [`tab_advance`] gives them, not the width of their glyph. +fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { + let mut x = x_start; + for (j, g) in sg.iter().enumerate().take(to).skip(from) { + x += if g.ch == '\t' { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; + } + x - x_start +} + /// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make /// millions of outlines. const MAX_LEADER_COPIES: f64 = 4096.0; @@ -1223,12 +1233,16 @@ fn word_width(g: &[SGlyph], i: usize) -> f64 { /// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. /// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when /// the line may hyphenate a word; a line whose text before the overflowing word already ends -/// within the zone breaks before that word. +/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop +/// (`tabs`), with the line's content starting `x_start` from the tab origin. +#[allow(clippy::too_many_arguments)] fn break_line( text: &str, g: &[SGlyph], i: usize, width: f64, + tabs: &[vectorcraft_doc::TabStop], + x_start: f64, hyphenate: Option<(&HyphenationOptions, f64)>, burasagari: Burasagari, kinsoku: Kinsoku, @@ -1245,7 +1259,11 @@ fn break_line( while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) { cluster_end += 1; } - let cluster_width: f64 = g[j..cluster_end].iter().map(|glyph| glyph.adv).sum(); + let cluster_width: f64 = if gl.ch == '\t' { + tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 + } else { + g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() + }; if j > i && !gl.is_space() && x + cluster_width > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { @@ -1390,7 +1408,7 @@ mod wrapping_tests { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard); + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -1403,11 +1421,11 @@ mod wrapping_tests { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, None, Burasagari::None, Kinsoku::Hard); + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); - let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, None, Burasagari::None, Kinsoku::Hard); + let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -1567,7 +1585,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese // single-line composer does). let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); - if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang { + // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths + // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). + let has_tab = sg.iter().any(|g| g.ch == '\t'); + if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { return None; } let m = Metrics::of(&sg[0]); @@ -1695,7 +1716,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { Some(&(e, h)) if e > i => (e, h), - _ if i < n => break_line(cx.text, &sg, i, width, hyphenate, para.burasagari, para.kinsoku), + _ if i < n => break_line(cx.text, &sg, i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), _ => (n, false), }; let m = Metrics::max(&sg[i..end]).unwrap_or(pm); @@ -1739,7 +1760,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } None => 0.0, }; - let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; + let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: // when it doesn't fit; Forced: always). The rest of the line is aligned and justified // without it, and it follows the line's last character. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d7c52af75..41718cf3d 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -486,6 +486,21 @@ fn tab_stops_position_text() { assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); } +#[test] +fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { + // A tab at the start of area type is 36 pt wide: the line breaks and justifies around that, + // so no line reaches past the frame's right edge (#2457). + for justify in [Justify::Left, Justify::JustifyLeft] { + let t = + area("\tSample copy flows here while the layout takes shape. Swap these words", style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify); + let lay = layout(db(), &t); + let right = lay.glyphs.iter().filter(|g| !t.plain_text()[g.byte..].starts_with(' ')).map(|g| g.origin.x + g.advance).fold(0.0, f64::max); + assert!(right <= 200.0 + 1e-6, "{justify:?}: the type reaches {right}"); + let first = lay.glyphs.iter().find(|g| g.byte == 1).map(|g| g.origin.x).unwrap(); + assert!((first - 36.0).abs() < 1e-6, "{justify:?}: the text after the tab starts at {first}"); + } +} + #[test] fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { use vectorcraft_doc::{TabAlign, TabStop}; From 848e7a296a55010c6b0f82a686987200e6a6c698 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:09:50 +0200 Subject: [PATCH 047/184] Dimension tool: a click near a line snaps the measured point onto the line (#2432) Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 14 ++++++++++++++ crates/tools/src/dimension.rs | 27 ++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index 83e7be165..05915505c 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -407,3 +407,17 @@ fn shift_shows_the_constrained_line_while_picking_the_second_point() { assert_eq!(end(&mut s, false).y, 310.0); assert!((end(&mut s, true).y - 300.0).abs() < 1e-9); } + +/// A click near a line, away from its anchors, snaps to the closest point of the line (#2432). +#[test] +fn clicking_near_a_line_snaps_the_measured_point_onto_it() { + let mut s = session(); + edge(&mut s); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + for (x, y) in [(130.0, 103.0), (150.0, 97.0), (140.0, 130.0)] { + ev(&mut s, PointerKind::Down, x, y); + ev(&mut s, PointerKind::Up, x, y); + } + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "20.00 pt"); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 9fd871c70..2cd39dfc6 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -119,6 +119,20 @@ fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize, .min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p))) } +/// The point of `edge` closest to `p`. +fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?; + if edge.segment >= sp.segment_count() { + return None; + } + let c = sp.segment(edge.segment); + let t = c.nearest(p, 1e-4).t; + use vectorcraft_geom::kurbo::ParamCurve; + let q = c.eval(t); + (q.x.is_finite() && q.y.is_finite()).then_some(q) +} + impl DimensionTool { fn target(&self, cx: &ToolContext, p: Point) -> Option { let edge = pick(cx, p)?; @@ -216,11 +230,14 @@ impl DimensionTool { Kind::Linear => { let (mut at, _) = crate::guides::snap_pick(cx, p); let mut reference = None; - if let Some(edge) = pick(cx, p) - && let Some((anchor, point)) = anchor_near(cx, &edge, p) - { - at = point; - reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + if let Some(edge) = pick(cx, p) { + if let Some((anchor, point)) = anchor_near(cx, &edge, p) { + at = point; + reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + } else if let Some(point) = nearest_on(cx, &edge, p) { + // Not on an anchor: snap to the closest point of the line (#2432). + at = point; + } } if mods.shift && let Some((last, _)) = self.points.last() From 2108c3cc9931d1b74b3d64415207fd2231c343f8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:02:55 +0200 Subject: [PATCH 048/184] Render: a glow's pixmap is drawn without the paint transform a pattern fill left (#2426) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_pattern.rs | 40 ++++++++++++++++++++++++++++++ crates/render/src/group.rs | 3 +++ 2 files changed, 43 insertions(+) diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 5b5789d95..9aeff6c70 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -115,6 +115,8 @@ fn edit_then_cancel_restores_definition() { fn edit_defaults_to_selected_objects_pattern() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); sel(&mut s, &[big]); @@ -129,6 +131,8 @@ fn edit_defaults_to_selected_objects_pattern() { fn set_fill_with_pattern_swatch() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots")); @@ -170,6 +174,8 @@ fn options_set_tile_type_spacing_and_overlap() { fn options_rename_updates_swatch_and_paints() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); @@ -182,6 +188,8 @@ fn options_rename_updates_swatch_and_paints() { fn delete_pattern_unpaints_objects() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); @@ -195,6 +203,8 @@ fn delete_pattern_unpaints_objects() { fn transform_patterns_only_moves_placement() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let before = node(&s, big).geometric_bounds(); @@ -212,6 +222,8 @@ fn transform_patterns_only_moves_placement() { fn transforms_without_transform_objects_move_only_the_pattern() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); s.execute("select.set", &json!({"ids": [big.0]})).unwrap(); @@ -296,6 +308,8 @@ fn hit_testing_in_edit_mode_only_reaches_tile_layer() { fn save_load_roundtrip_keeps_patterns_and_repeats() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let r = rect(&mut s, 300.0, 50.0, 10.0, 10.0); @@ -312,6 +326,8 @@ fn save_load_roundtrip_keeps_patterns_and_repeats() { fn svg_export_writes_pattern_def() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let svg = vectorcraft_svg::export(doc(&s), &vectorcraft_svg::ExportOptions::default()); @@ -329,6 +345,8 @@ fn svg_export_writes_pattern_def() { fn pdf_export_expands_pattern_fills() { let mut s = session(); make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let rep = vectorcraft_pdf::export_with_report(doc(&s), &Default::default()).unwrap(); @@ -587,3 +605,25 @@ fn picking_another_pattern_swatch_keeps_the_pattern_transform() { assert_eq!(pattern, "Dots"); assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0]); } + +#[test] +fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer() { + // #2426: the glow's pixmap was drawn with the pattern's paint transform, so it landed + // scaled at the top left instead of along the object's edges. + let mut s = session(); + make_dots(&mut s); + let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); + s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); + let big = rect(&mut s, 100.0, 100.0, 150.0, 150.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + sel(&mut s, &[big]); + s.execute("effect.apply", &json!({"effect": "stylize.innerGlow", "params": {"blur": 10}})).unwrap(); + let mut r = vectorcraft_render::Renderer::new(); + r.threads = 2; + let view = Affine::scale(1.5) * Affine::translate((-20.0, -30.0)); + let out = r.render(&s.doc().unwrap().doc, 400, 400, view, &Default::default()); + let [red, ..] = out.pixel(110, 100); + assert!(red < 0x30, "glow outside the object: red {red}"); + let [red, ..] = out.pixel(123, 200); + assert!(red > 0x40, "no glow along the edge: red {red}"); +} diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index e32be592d..c7f7b5acd 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -274,6 +274,9 @@ pub(crate) fn draw_pixmap(ctx: &mut RenderContext, pm: Arc, at: (f64, f6 #[cfg(test)] PIXELS_DRAWN.with(|n| n.set(n.get() + u64::from(pm.width()) * u64::from(pm.height()))); ctx.set_transform(Affine::translate(at)); + // The pixmap maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern, + // radial or freeform gradient) would resample it (#2426). + ctx.reset_paint_transform(); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); ctx.set_transform(Affine::IDENTITY); From 8875b3fbca7ab7557ec51114ee3fcdfd1c992701 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:07:11 +0200 Subject: [PATCH 049/184] Live Corners: a corner rounds until its cut reaches its neighbours (#2332) A corner's radius was capped at half its shorter side whatever its neighbours did, so one corner of a 100 pt square rounded alone stopped at 50 pt. Each corner now rounds until its cut reaches the cuts its neighbours ask for: the whole side when the neighbour is square, halfway when both ask for more. The corner widget drag, the red limit outline and CSS border-radius export follow the same limit. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/corners.rs | 13 +++- crates/geom/src/corners.rs | 117 ++++++++++++++++++++++++++++++---- crates/geom/src/shapes.rs | 23 ++++++- crates/svg/src/css.rs | 6 +- crates/svg/tests/css_rules.rs | 6 +- crates/tools/src/corners.rs | 58 ++++++++++++++--- docs/ui-parity.md | 5 +- 7 files changed, 192 insertions(+), 36 deletions(-) diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index 986442b29..ccf884fda 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use std::collections::BTreeSet; -use vectorcraft_geom::corners::{Corner, cut_corners, path_corners}; +use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners}; use vectorcraft_geom::shapes::{self, CornerKind}; use vectorcraft_geom::{Affine, PathData, Rect}; @@ -23,6 +23,8 @@ pub struct LiveCorners<'a> { /// The corners that can be cut, in anchor order ([`path_corners`] of `base`). pub corners: Vec, radii: Vec, + /// The radius each of `corners` is drawn with ([`fitted_radii`]). + drawn: Vec, kinds: Vec, /// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from. sources: Vec>, @@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> { }) .collect() }; - Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources }) + let corners = path_corners(&base); + let drawn = fitted_radii(&corners, &radii); + Some(Self { corners, base, xf, radii, drawn, kinds, sources }) } /// The Live Corners of a path object. @@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> { self.radii.get(index).copied().unwrap_or(0.0) } + /// The radius corner `index` is drawn with, in document units: its own, no larger than its + /// neighbours leave room for (0 for an anchor that is no corner). + pub fn drawn_radius(&self, index: usize) -> f64 { + self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0) + } /// The kind corner `index` is cut with. pub fn kind(&self, index: usize) -> CornerKind { self.kinds.get(index).copied().unwrap_or_default() diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 31e03026a..16120aa0e 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -76,6 +76,10 @@ pub struct Corner { /// The sides themselves, leaving the corner, in the same order. pub side_u: Side, pub side_v: Side, + /// The anchor indices (in the path) of the anchors at the other ends of the sides, in the + /// same order. + pub before: usize, + pub after: usize, } impl Corner { @@ -84,8 +88,10 @@ impl Corner { /// sides leave it straight on. fn of(sp: &SubPath, i: usize, index: usize) -> Option { let n = sp.anchors.len(); - let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) }; - let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?); + let (prev_i, next_i) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) }; + let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev_i)?, sp.anchors.get(next_i)?); + let first = index.checked_sub(i)?; + let (before, after) = (first + prev_i, first + next_i); if a.kind == AnchorKind::Smooth { return None; } @@ -96,7 +102,7 @@ impl Corner { if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) { return None; } - let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v }; + let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v, before, after }; (c.sin() > MIN_SINE).then_some(c) } @@ -120,19 +126,51 @@ impl Corner { r * (1.0 + self.cos()) / self.sin() } - /// The largest radius the corner draws: its cut then reaches halfway along the shorter side, - /// so neighbouring corners at their largest meet without overlapping (half the shorter side - /// at a rectangle's corners). - pub fn max_radius(&self) -> f64 { - self.lu.min(self.lv) / 2.0 * self.sin() / (1.0 + self.cos()) + /// The radius of a round cut that meets each side `s` from the corner (the inverse of + /// [`Corner::setback`]). + pub fn radius_at_setback(&self, s: f64) -> f64 { + s * self.sin() / (1.0 + self.cos()) } - /// The radius the corner is drawn with: `radius`, no larger than [`Corner::max_radius`], 0 when - /// negative or not a number. + /// The largest radius the corner draws whatever its neighbours do: its cut then reaches + /// halfway along the shorter side, so neighbouring corners at their largest meet without + /// overlapping (half the shorter side at a rectangle's corners). + pub fn max_radius(&self) -> f64 { + self.radius_at_setback(self.lu.min(self.lv) / 2.0) + } + + /// The largest radius the corner draws when the cuts of the corners at the other ends of its + /// sides reach `before` along side u and `after` along side v: its cut reaches halfway along + /// a side, or further, up to where the other end's cut begins, so all of a side whose other + /// end isn't cut (#2332). + pub fn max_radius_beside(&self, before: f64, after: f64) -> f64 { + let room = |len: f64, other: f64| (len - other.max(0.0)).max(len / 2.0); + self.radius_at_setback(room(self.lu, before).min(room(self.lv, after))) + } + + /// The radius the corner is drawn with whatever its neighbours do: `radius`, no larger than + /// [`Corner::max_radius`], 0 when negative or not a number. pub fn fitted(&self, radius: f64) -> f64 { radius.max(0.0).min(self.max_radius()) } + /// The largest radius the corner draws when it and the corners for which `edited` holds are + /// set to one radius together, the other corners of `corners` keeping their `radii` (by + /// anchor index). A side shared with an edited corner takes both cuts as far as they fit + /// side by side, or halfway each; on a side to any other corner the cut reaches up to where + /// that corner's cut begins ([`Corner::max_radius_beside`]). + pub fn max_shared_radius(&self, corners: &[Corner], radii: &[f64], edited: impl Fn(usize) -> bool) -> f64 { + let side = |len: f64, other: usize| match find(corners, other) { + Some(c) if other == self.index || edited(other) => { + let both = self.setback(1.0) + c.setback(1.0); + let apart = if both > EPS { len / both } else { f64::INFINITY }; + apart.max(self.radius_at_setback(len / 2.0)) + } + _ => self.radius_at_setback((len - requested_setback(corners, radii, other)).max(len / 2.0)), + }; + side(self.lu, self.before).min(side(self.lv, self.after)) + } + /// The point `k` along the bisector, measured in (u + v): the centre of a round cut of radius /// `r` is at `k = r / sin`. pub fn on_bisector(&self, k: f64) -> Point { @@ -237,15 +275,43 @@ pub fn path_corners(path: &PathData) -> Vec { out } +/// The corner at anchor `index` among `corners` (in anchor order). +fn find(corners: &[Corner], index: usize) -> Option<&Corner> { + corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| corners.get(i)) +} + +/// How far along each side the cut of the corner at anchor `index` asks to reach, with the radius +/// in `radii` there (0 for an anchor that is no corner, or a radius that is negative or not a +/// number). +fn requested_setback(corners: &[Corner], radii: &[f64], index: usize) -> f64 { + find(corners, index).map_or(0.0, |c| c.setback(radii.get(index).copied().unwrap_or(0.0).max(0.0))) +} + +/// The radius each of `corners` (in anchor order) is drawn with, given the radii by anchor index: +/// its own, no larger than leaves room for the cuts of its neighbours ([`Corner::max_radius_beside`] +/// with the setbacks their radii ask for), so a corner whose neighbours are square rounds until +/// its cut reaches them (#2332) and two neighbours never cut past each other. +pub fn fitted_radii(corners: &[Corner], radii: &[f64]) -> Vec { + corners + .iter() + .map(|c| { + let own = radii.get(c.index).copied().unwrap_or(0.0).max(0.0); + let max = c.max_radius_beside(requested_setback(corners, radii, c.before), requested_setback(corners, radii, c.after)); + own.min(max) + }) + .collect() +} + /// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in /// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits -/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is +/// ([`fitted_radii`]). A cut corner becomes two anchors, and a curved side between corners is /// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that /// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each /// anchor comes from. pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec>) { let corners = path_corners(path); - let mut corners = corners.iter().peekable(); + let fitted = fitted_radii(&corners, radii); + let mut corners = corners.iter().zip(fitted).peekable(); let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len())); let mut first = 0; for sp in &path.subpaths { @@ -254,8 +320,7 @@ pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (Pat let cuts: Vec> = (0..n) .map(|i| { let index = first + i; - let c = corners.next_if(|c| c.index == index)?; - let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0)); + let (c, radius) = corners.next_if(|(c, _)| c.index == index)?; (radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default())) }) .collect(); @@ -470,4 +535,28 @@ mod tests { assert!((bez.eval(t).distance(c.at) - r).abs() < r * 1e-3); } } + + /// #2332: a corner rounds until its cut reaches its neighbours' (all of a side whose other + /// end is square); neighbours that both ask for more than half a side meet halfway. + #[test] + fn a_corner_rounds_up_to_its_neighbours_cuts() { + let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let corners = path_corners(&square); + let drawn = |radii: [f64; 4]| fitted_radii(&corners, &radii); + assert_eq!(drawn([0.0, 500.0, 0.0, 0.0]), [0.0, 100.0, 0.0, 0.0]); + assert_eq!(drawn([30.0, 500.0, 0.0, 0.0]), [30.0, 70.0, 0.0, 0.0]); + assert_eq!(drawn([80.0, 500.0, 0.0, 0.0]), [50.0, 50.0, 0.0, 0.0]); + assert_eq!(drawn([500.0; 4]), [50.0; 4]); + assert_eq!(drawn([f64::NAN, 120.0, -1.0, 0.0]), [0.0, 100.0, 0.0, 0.0]); + // The lone cut is a quarter circle from one neighbour to the other. + let (cut, _) = cut_corners(&square, &[0.0, 500.0, 0.0, 0.0], &[]); + let ends: Vec = cut.anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(ends, [Point::new(0.0, 0.0), Point::new(0.0, 0.0), Point::new(100.0, 100.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]); + // Dragged together with its neighbour, a corner meets it halfway; beside a square one, + // it goes all the way. + let tr = corners[1]; + assert!((tr.max_shared_radius(&corners, &[0.0; 4], |k| k == 0) - 50.0).abs() < 1e-9); + assert!((tr.max_shared_radius(&corners, &[0.0; 4], |_| false) - 100.0).abs() < 1e-9); + assert!((tr.max_shared_radius(&corners, &[0.0, 0.0, 40.0, 0.0], |_| false) - 60.0).abs() < 1e-9); + } } diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 5aa16b639..54a7ad933 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -59,8 +59,23 @@ pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 { radius.max(0.0).min(max_corner_radius(w, h)) } +/// The radii the corners of a `w` × `h` rectangle (top-left, top-right, bottom-right, +/// bottom-left) are drawn with: each its own, no larger than leaves room for its neighbours' +/// cuts ([`crate::corners::fitted_radii`]): half the shorter side when they are all as large, up +/// to a whole side when its neighbours are square (#2332); 0 when negative or not a number. +pub fn fitted_corner_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] { + let corners = crate::corners::path_corners(&rectangle(Rect::new(0.0, 0.0, w, h))); + let mut out = [0.0; 4]; + for (c, r) in corners.iter().zip(crate::corners::fitted_radii(&corners, &radii)) { + if let Some(o) = out.get_mut(c.index) { + *o = r; + } + } + out +} + /// Rectangle whose corners (top-left, top-right, bottom-right, bottom-left, y down) each have -/// their own radius and kind, every radius clamped to half the shorter side. A corner without a +/// their own radius and kind, every radius no larger than [`fitted_corner_radii`] leaves. A corner without a /// radius is one anchor, the others two (where the cut meets each side). Clockwise from the /// top-left corner, its anchor on the top side first. Cut as any path's corners are /// ([`crate::corners::cut_corners`]). @@ -379,9 +394,11 @@ mod tests { let chamfer = rectangle_with_corners(r, radii, [CornerKind::Chamfer; 4]); assert!((chamfer.subpaths[0].area() - (6000.0 - 50.0)).abs() < 1e-9); assert!(chamfer.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()), "straight"); - // Radii clamp to half the shorter side; negative and NaN radii are none. + // Radii clamp to what their neighbours leave, the whole shorter side when they are + // square (#2332): the cut reaches the bottom-left corner. Negative and NaN radii are none. let p = rectangle_with_corners(r, [500.0, -5.0, f64::NAN, 0.0], [CornerKind::Round; 4]); - assert_eq!(p.subpaths[0].anchors[0].p, Point::new(30.0, 0.0)); + assert_eq!(p.subpaths[0].anchors[0].p, Point::new(60.0, 0.0)); + assert_eq!(p.subpaths[0].anchors.last().map(|a| a.p), Some(Point::new(0.0, 60.0))); assert_eq!(p.anchor_count(), 5); assert_eq!(CornerKind::ALL.map(CornerKind::next), [CornerKind::InvertedRound, CornerKind::Chamfer, CornerKind::Round]); } diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index aef875a40..34e74269e 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -622,9 +622,9 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option { Some(LiveShape::Rectangle { w, h, radii, kinds, xf }) if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) => { - // The radii as drawn, none past half the shorter side (CSS draws one further when its - // neighbours leave room). - let radii = radii.map(|r| shapes::fitted_corner_radius(*w, *h, r)); + // The radii as drawn: no two neighbours' cuts add up to more than their side, so CSS + // draws them as they are. + let radii = shapes::fitted_corner_radii(*w, *h, *radii); if radii.iter().all(|r| *r <= 0.0) { Some(Corners::Square) } else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) { diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/css_rules.rs index 654a522db..2b4a203d9 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/css_rules.rs @@ -84,13 +84,13 @@ fn live_corners_give_per_corner_radii_and_only_round_ones() { kinds[1] = CornerKind::InvertedRound; } assert!(prop(&one(&d, id, &CssOptions::default()), "border-radius").is_none(), "CSS can't cut a corner in"); - // A radius past half the shorter side is drawn at half of it (#442), and exported so: CSS - // would draw it larger, its neighbours leaving room. + // A radius is drawn no larger than its neighbours leave room for, past half the shorter + // side when they are small (#2332), and exported as drawn: 50 less the 4 below it. if let Some(LiveShape::Rectangle { radii, kinds, .. }) = d.node_mut(id).and_then(|n| n.live_shape_mut()) { *radii = [60.0, 8.0, 0.0, 4.0]; *kinds = Default::default(); } - assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("25px 8px 0 4px")); + assert_eq!(prop(&one(&d, id, &CssOptions::default()), "border-radius"), Some("46px 8px 0 4px")); } #[test] diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index affb049f2..c3be27432 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -29,8 +29,10 @@ const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX; struct Widget { /// The corner, in the corners' own space. corner: Corner, - /// The radius it is set to (document units) and its kind. + /// The radius it is set to (document units), the one it is drawn with (no larger than its + /// neighbours leave room for) and its kind. radius: f64, + drawn: f64, kind: CornerKind, /// The angle between its sides in the document, in degrees. angle: f64, @@ -76,9 +78,9 @@ impl PathWidgets { .map(|c| { // On the bisector at the centre of the arc as drawn, at least the minimum inset // in and at most half the shorter side. - let radius = lc.radius(c.index); - let k = (c.fitted(radius) / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0); - Widget { corner: *c, radius, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) } + let (radius, drawn) = (lc.radius(c.index), lc.drawn_radius(c.index)); + let k = (drawn / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0); + Widget { corner: *c, radius, drawn, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) } }) .collect(); let all = widgets.len() == lc.corners.len(); @@ -217,6 +219,8 @@ pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option, radii: Vec, kinds: Vec, xf: Affine, @@ -278,7 +282,7 @@ impl CornerDrag { let lc = cx.doc.node(w.id).and_then(LiveCorners::of)?; let n = lc.base.anchor_count(); let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect()); - Some(Outline { base: lc.base.into_owned(), radii, kinds, xf: w.xf }) + Some(Outline { corners: lc.corners.clone(), base: lc.base.into_owned(), radii, kinds, xf: w.xf }) }) .collect::>>()?; Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p, click }) @@ -293,15 +297,23 @@ impl CornerDrag { return (self.radius, self.at_limit); }; let inv = xf.inverse(); - let max = w.corner.max_radius(); - let r = w.corner.fitted(w.radius) + w.corner.radius_change(inv * p - inv * self.start); + let max = self.max_radius(path, &w.corner); + let r = w.drawn + w.corner.radius_change(inv * p - inv * self.start); (r.min(max).max(0.0), max > 0.0 && r >= max) } + /// The largest radius `corner` of shown path `path` draws while the drag sets every shown + /// corner of that path to one radius: up to the cuts of its neighbours, as far as its own + /// sides when they are square (#2332). + fn max_radius(&self, path: usize, corner: &Corner) -> f64 { + let (Some(shown), Some(o)) = (self.widgets.paths.get(path), self.outlines.get(path)) else { return corner.max_radius() }; + corner.max_shared_radius(&o.corners, &o.radii, |k| shown.widgets.iter().any(|w| w.corner.index == k)) + } + /// The cuts of the edited corners that are at their largest radius, in document coordinates. fn corners_at_limit(&self) -> BezPath { let mut out = BezPath::new(); - for (shown, o) in self.widgets.paths.iter().zip(&self.outlines) { + for (n, (shown, o)) in self.widgets.paths.iter().zip(&self.outlines).enumerate() { let shown = &shown.widgets; let mut radii = o.radii.clone(); for w in shown { @@ -309,7 +321,7 @@ impl CornerDrag { *r = self.radius; } } - let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= w.corner.max_radius() - 1e-9); + let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= self.max_radius(n, &w.corner) - 1e-9); let (path, sources) = cut_corners(&o.base, &radii, &o.kinds); for (sp, from) in path.transformed(o.xf).subpaths.iter().zip(&sources) { // A corner's cut is the segment between its two anchors (the last one, for a @@ -611,6 +623,34 @@ mod tests { assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]); } + /// #2332: one corner rounded alone goes past half the side, until its cut reaches the + /// neighbouring anchors (the whole side of a square), or a rounded neighbour's cut. + #[test] + fn a_corner_rounded_alone_rounds_up_to_its_neighbours() { + for (top_right, max) in [(0.0, 100.0), (30.0, 70.0)] { + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0, top_right, 0.0, 0.0], + kinds: Default::default(), + xf: Affine::translate((100.0, 100.0)), + }; + let (d, id) = doc_with(live.to_path(), Some(live)); + // Direct Selection picked the bottom-right anchor. + let mut s = selected(id); + s.anchors.insert(id, [(0, if top_right > 0.0 { 3 } else { 2 })].into()); + let p = paint(); + let cx = cx(&d, &s, &p); + assert_eq!(CornerWidgets::of(&d, &s, 1.0, true).unwrap().corners(), [2]); + let mut t = crate::create("directSelection"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 190.0)); + assert!((radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[1]) - 60.0).abs() < 1e-9); + assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "60 is below the limit"); + assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.0, 0.0))[0]), max); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the limit shows"); + } + } + #[test] fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() { let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0))); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9e17ae045..9f963b248 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Live Corners radius up to the neighbouring cuts, #2332; Image Trace quick presets: wording corrected to the shipped behaviour, the Image Trace button opens the controls; batch exporting, #1935; document window scroll bars, #2322; Anchor Point tool: a click on a handle end retracts that handle only, #2267; scores unchanged; Smart Guides: the dragged end of a straight segment keeps to its extension at any angle, #2120; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -83,7 +83,7 @@ tools, never checked side by side) isn't captured row by row. | Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | Open-path anchor Delete removes adjoining segments without bridging the gap; targeted core and full-frame Delete/Backspace regressions pass for survivor selection and Undo/Redo. Explicit Remove Anchor Points keeps its reconnect/refit behavior; #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior; #1674: Delete removes every selected anchor of a closed path and objects selected whole along with them, and keeps the remainder selected; #2241: Remove Anchor Points then selects the next anchor along the path (the one before at an open end), so it can be run again without picking each anchor | | Direct Selection and Group Selection pick only inside the isolated object in isolation mode | done | #1666 | | Escape cancels a Direct Selection drag | done | #1803 (#1743) | -| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512) | +| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512); a corner rounds until its cut reaches its neighbours' cuts, so one corner of a square alone reaches the whole side (#2332) | | Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing | | Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing | | Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) | @@ -234,6 +234,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Live Corners: a corner rounds until its cut reaches the neighbouring anchors or their cuts, so one corner of a 100 pt square alone reaches a 100 pt radius instead of 50 (#2332); score unchanged (estimated) | | 2026-10-16 | minor | Correction: quick Image Trace presets trace at once and leave the panel as it was (the 2026-10-14 row below said a quick preset reveals it; the later change in `baadbe2c6` made the button the way in); the Image Trace button in the Control bar or Properties opens the controls, as the public documentation describes. Wording only; scores and hours unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. | From 7cd6b5d983a75fcd9e9c44febdc2fb39b8fd2672 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:20:49 +0200 Subject: [PATCH 050/184] Effects: Expand Appearance and export give each Transform copy of a gradient path its own gradient (#2490) A path with a Transform effect that makes copies and a gradient was still merged into one path by bake_leaf and expand_leaf, so the gradient spanned every copy. Route it through reshape / expand_outlined like the renderer does. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/bake.rs | 39 ++++++++++++++++++++++++++++++ crates/effects/src/reshape.rs | 7 +++++- crates/engine/src/cmd/effectcmd.rs | 28 +++++++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index e0165c2ef..0f5b89d1c 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -120,6 +120,13 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { let m = reshape(n, symbol.as_deref())?; return Some(bake_node(d, &m).unwrap_or(m)); } + // A path whose Transform effect copies it with a gradient: each copy an object of its own, so + // its gradient fits it rather than spanning every copy. + if crate::has_gradient_copies(n) + && let Some(m) = reshape(n, None) + { + return Some(bake_pieces(d, m)); + } // Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that // Transform effects only move stays type). The new pieces (results, outlines, copies, the // fills' and strokes' art) get ids of their own. @@ -968,4 +975,36 @@ mod tests { // An effect that also scales. assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none()); } + /// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies. + fn gradient_copies(id: u64) -> Node { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); + n + } + + /// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right. + fn gradient_pieces(n: &Node) -> Vec { + let mut out = vec![]; + let mut walk = vec![n]; + while let Some(n) = walk.pop() { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)), + _ => { + assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}"); + out.extend(n.geometric_bounds().map(|b| b.width())); + } + } + } + out + } + + #[test] + fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { + let d = bake_document(&doc_with(gradient_copies(50))).unwrap(); + // Three squares of their own, not one path spanning all three. + assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index fa59cae55..e0c469718 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -198,10 +198,15 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { as_art(n, symbol_art) } -/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has +/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand +/// Appearance: [`reshape`]d when it has /// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None` /// when it has neither. pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { + // A path whose Transform effect copies it with a gradient: each copy an object of its own. + if has_gradient_copies(n) { + return as_art(n, symbol_art); + } let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty(); if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) { return None; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 1781f3355..abffe675c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -504,10 +504,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b expand_pieces(d, &mut m, false, &mut stroke_art); m }) - } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) { effects::expand_leaf(d, &v, &mut stroke_art) } else { - // Type, images, symbol instances, live objects: through their outlines. + // Type, images, symbol instances, live objects (and paths whose Transform effect copies + // them with a gradient): through their outlines. let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, @@ -995,4 +996,27 @@ mod tests { assert_eq!(ch.len(), 1); assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6); } + #[test] + fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + let (mut s, id) = session_with_rect(); + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + s.edit("gradient", |d, _| { + let n = d.node_mut(id).unwrap(); + n.appearance.set_fill(ramp); + Ok(()) + }) + .unwrap(); + s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap(); + s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap(); + let mut boxes = vec![]; + node(&s, id).walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) { + boxes.push(n.geometric_bounds().unwrap().width()); + } + }); + assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient"); + } } From d3751cc07bfe366a9cba188cf0df80a04121d88a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 19:53:49 +0200 Subject: [PATCH 051/184] Effects: Zig Zag Smooth points draw a continuous wave (#2343) Smooth Zig Zag ran the ridge points through a Catmull-Rom spline. Its end tangents slant across the path, and where the ridges are taller than about twice their spacing the sharp-turn floor of turn_tangent lengthens the handles, so the result read as straight diagonals with rounded tips. Each peak and valley is now a smooth point whose handles run along the path, a third of the way to the neighbouring ridge, so every span follows a half cosine through the path. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort.rs | 42 ++++++++++++++++++++++++++--------- crates/effects/src/tests.rs | 25 +++++++++++++++++++++ docs/effects-parity.md | 5 +++-- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index b7cb564d5..502cf962a 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -267,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData { const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4; +/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the +/// path to the ridges before and after it (zero at an open path's ends). +struct Ridge { + p: Point, + dir: Vec2, + gap_in: f64, + gap_out: f64, +} + /// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately -/// displaced along the normal. +/// displaced along the normal. Corner points join them with straight lines. Smooth points make a +/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the +/// way to the next ridge, so each span between them follows a half cosine (an S through the path) +/// whatever the size of the ridges (#2343). pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { let size = num(p, "size", 10.0).clamp(-1e4, 1e4); let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size }; @@ -277,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { let mut subs = vec![]; for sp in &path.subpaths { let segs = sp.segment_count(); - let mut pts = vec![]; + let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64; + let mut pts: Vec = vec![]; let mut k = 0usize; for i in 0..segs { let c = sp.segment(i); // Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up // near the anchors, and so does any uneven curve. let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY); + let step = len / (ridges + 1) as f64; for j in 0..=ridges { let s = j as f64 / (ridges + 1) as f64; let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s }; + let mut gap_in = step; let nrm = if j == 0 { // Average the normals of the segments meeting at the anchor. let prev = if i > 0 { - Some(sp.segment(i - 1)) + Some(i - 1) } else if sp.closed { - Some(sp.segment(segs - 1)) + Some(segs - 1) } else { None }; + gap_in = prev.map_or(0.0, gap); let n1 = normal_at(&c, 0.0); match prev { - Some(pc) => { - let s = n1 + normal_at(&pc, 1.0); + Some(pi) => { + let s = n1 + normal_at(&sp.segment(pi), 1.0); if s.hypot() > 1e-9 { s / s.hypot() } else { n1 } } None => n1, @@ -308,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { normal_at(&c, t) }; let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - pts.push(c.eval(t) + nrm * (amp * sign)); + pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step }); k += 1; } } @@ -316,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { if segs > 0 { let c = sp.segment(segs - 1); let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign)); + let nrm = normal_at(&c, 1.0); + pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 }); } else { - pts.extend(sp.anchors.iter().map(|a| a.p)); + pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 })); } } - subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) }); + subs.push(if smooth { + let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect(); + SubPath::new(anchors, sp.closed) + } else { + SubPath::polyline(&pts.iter().map(|r| r.p).collect::>(), sp.closed) + }); } PathData::new(subs) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 9b90995bc..f075e4095 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -510,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() { } } +/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and +/// valley is a smooth point whose handles run parallel to the line, and the curve between them +/// follows a cosine, even where the ridges are much taller than they are apart. +#[test] +fn zigzag_smooth_is_a_wave() { + use vectorcraft_geom::kurbo::ParamCurve; + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false)); + for size in [3.0, 30.0] { + let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line); + let sp = &out.subpaths[0]; + assert_eq!(sp.anchors.len(), 11); + for a in &sp.anchors { + assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}"); + } + for i in 0..sp.segment_count() { + let c = sp.segment(i); + for k in 0..=20 { + let q = c.eval(k as f64 / 20.0); + let want = -size * (std::f64::consts::PI * q.x / 10.0).cos(); + assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})"); + } + } + } +} + /// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in /// turn all the way round. #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 09ac1f415..2379ba760 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---|---:|---| | Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — | | Crop Marks | Crop Marks | 1/1 | — | -| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | +| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) | | Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) | | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap | | Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) | @@ -99,6 +99,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Zig Zag with Smooth points draws a continuous wave: every peak and valley is a smooth point with handles along the path, a third of the way to the next ridge, so each span follows a half cosine through the path instead of a straight diagonal with rounded tips, also at the ends of open paths and for ridges much taller than they are apart (#2343); score unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-15 | minor | Round Corners rounds corners whose anchor carries a handle (where a curve meets a line, or two curves meet) with the round cut Live Corners uses (#1144), reaching `radius` along each side; before, they were left sharp. Corners between handle-less anchors are rounded as before; score unchanged (estimated) | | 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding | From 4f30e523680903f750c83a5b4d5f0f521f6d3e57 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 19:56:57 +0200 Subject: [PATCH 052/184] Type: text.create and text.createInPath return the new type's bounds (#2413) Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/create.rs | 3 ++- crates/engine/src/cmd/textedit.rs | 5 +++-- crates/engine/src/cmd/typecmd.rs | 35 +++++++++++++++++++++++++++++++ crates/engine/src/inspect.rs | 2 +- docs/mcp.md | 3 ++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index fd2486c74..23abe9389 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -512,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { } let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?; let id = made.get("id").and_then(Value::as_u64).map(NodeId); + let made = super::typecmd::with_bounds(s, id, made); Ok(super::typecmd::with_overflow(s, id.as_slice(), made)) } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index a18de08ed..7e2cd2ec6 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -609,7 +609,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(id) })?; - Ok(super::typecmd::with_overflow(s, &[id], json!({"id": id.0}))) + let made = super::typecmd::with_bounds(s, Some(id), json!({"id": id.0})); + Ok(super::typecmd::with_overflow(s, &[id], made)) } /// What `type.step` changes. diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 9a1b6bd11..0ebe71c12 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -269,6 +269,17 @@ pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> V result } +/// Adds `bounds` to the result of a command that made the type object `id`: the same +/// `{x, y, width, height}` box `document.inspect` reports for it (after `justify`, so a script can +/// place the new type without a helper object); `null` when it has none. +pub(crate) fn with_bounds(s: &Session, id: Option, mut result: Value) -> Value { + let Ok(st) = s.doc() else { return result }; + let symbols = st.doc.symbol_boxes(); + let b = id.and_then(|i| st.doc.node(i)).and_then(|n| n.geometric_bounds_with(&symbols)); + result["bounds"] = crate::inspect::rect_json(b); + result +} + /// The warning that Print gives for `ids`, area type and type on a path whose text doesn't fit /// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names /// the first 20 ids and counts the rest; `None` when `ids` is empty. @@ -783,6 +794,30 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { mod area_tests { use super::*; + /// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box + /// `document.inspect` reports for it, so a script can place it without a helper object. + #[test] + fn text_create_returns_the_inspect_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + let inspected = |s: &mut Session, id: &Value| { + let doc = s.execute("document.inspect", &json!({})).unwrap(); + fn find(v: &Value, id: &Value) -> Option { + if &v["id"] == id { + return Some(v["bounds"].clone()); + } + v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id)) + } + find(&doc, id).unwrap() + }; + let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap(); + assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}"); + assert_eq!(made["bounds"], inspected(&mut s, &made["id"])); + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap(); + let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap(); + assert_eq!(area["bounds"], inspected(&mut s, &area["id"])); + } + /// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on /// it, or ending there), and `text.setStyle` moves it the same way afterwards. #[test] diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 29343bd74..c5fe27670 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -6,7 +6,7 @@ use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, S use crate::Session; -fn rect_json(r: Option) -> Value { +pub(crate) fn rect_json(r: Option) -> Value { match r { Some(r) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }), None => Value::Null, diff --git a/docs/mcp.md b/docs/mcp.md index f77f4b2b7..5f95424d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3165,7 +3165,8 @@ Text that doesn't fit is drawn up to the last line that does, so a one-line head draws nothing. The commands that make type or change what it holds say so in their own reply: `text.create`, `text.createInPath`, `text.setText` and `text.setStyle` add `overflow: [id…]` (the area type and type on a path among the objects they made or changed whose text doesn't fit), and the `add_text` tool adds `overflow: true`. The key is -left out when everything fits. The warnings of `file.print` and `print.preview` give the ids of the objects that print +left out when everything fits. `text.create` and `text.createInPath` also return `bounds: {x, y, width, height}`, the new type's box +as `document.inspect` reports it (after `justify`), so a script can place the type without a helper object (#2413). The warnings of `file.print` and `print.preview` give the ids of the objects that print and whose text doesn't fit: the first 20 ids, then how many more. ```json From 285a3da81a21ca781526fb8b870667f1ccf3ef87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 20:12:40 +0200 Subject: [PATCH 053/184] Type: centre and right aligned lines set their tabs as if they started at the left (#2457) A tab's width depends on where it falls from the tab origin. Line breaking and the line width measure it from the left edge, but a centred or right aligned line is placed from its own start, so its tabs stopped elsewhere. The line is now laid out with its tabs measured from the left and then aligned as a whole. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 5 ++++- crates/text/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0559730e2..5b0f200f9 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1837,6 +1837,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let mut x_end = start_x; // Tab stops are measured from the frame's left edge (point type: the origin). let tab_origin = if regions.is_some() { x0 } else { 0.0 }; + // A line with tabs is measured as if it started at the left (see `set_width`), then + // aligned as a whole: its tabs stop where they were measured, however it is aligned. + let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l }; let line_start = sg.get(i).map_or(pr.start, |g| g.byte); let line_end = sg.get(end).map_or(pr.end, |g| g.byte); let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); @@ -1854,7 +1857,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } let mut leader = ""; if g.ch == '\t' { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); + (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); } else if j < body { if g.is_space() { adv += per_space; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 41718cf3d..03a0ec18b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -501,6 +501,24 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { } } +#[test] +fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() { + use vectorcraft_doc::{TabAlign, TabStop}; + // "AB" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line + // moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457). + for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] { + let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify); + t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let lay = layout(db(), &t); + let at = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap(); + assert!((at(2) - at(0) - 100.0).abs() < 1e-6, "{justify:?}: A at {}, B at {}", at(0), at(2)); + if matches!(justify, Justify::Right | Justify::JustifyRight) { + let b = lay.glyphs.iter().find(|g| g.byte == 2).unwrap(); + assert!((b.origin.x + b.advance - 200.0).abs() < 1e-6, "{justify:?}: the line ends at {}", b.origin.x + b.advance); + } + } +} + #[test] fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { use vectorcraft_doc::{TabAlign, TabStop}; From 4dcd0b6caaabd5d5885ed53b7602646f1c6ed573 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:28:01 +0200 Subject: [PATCH 054/184] Dimension tool: show where a Linear click snaps before the click (#2516) The snap ring on an anchor (or a dot on a line) and the rubber band now follow the snapped point, so both measured points visibly land on anchors. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/dimensions.rs | 31 +++++++++++++++++++++++ crates/tools/src/dimension.rs | 42 ++++++++++++++++++++++--------- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/dimensions.rs index 05915505c..99c415a75 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/dimensions.rs @@ -421,3 +421,34 @@ fn clicking_near_a_line_snaps_the_measured_point_onto_it() { let id = s.doc().unwrap().selection.objects[0].0; assert_eq!(text(&s, id), "20.00 pt"); } + +/// Both picked points snap onto anchors a few pixels away, the rubber band and a ring show the +/// snap before the click, and the dimension stays linked to the anchors (#2516). +#[test] +fn both_points_snap_onto_anchors_with_a_visible_indicator() { + use vectorcraft_engine::tools::Overlay; + let mut s = session(); + let line = edge(&mut s); + s.select_tool("dimension", ViewInfo::default()).unwrap(); + ev(&mut s, PointerKind::Move, 103.0, 102.0); + let ring = |s: &mut Session| { + s.overlays(ViewInfo::default()) + .into_iter() + .any(|o| matches!(o, Overlay::Anchor { p, size, .. } if size > 8.0 && p.x == 100.0 && p.y == 100.0)) + }; + assert!(ring(&mut s), "ring on the anchor before the first click"); + ev(&mut s, PointerKind::Down, 103.0, 102.0); + ev(&mut s, PointerKind::Up, 103.0, 102.0); + ev(&mut s, PointerKind::Move, 169.0, 98.0); + let end = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap(); + assert_eq!((end.x, end.y), (172.0, 100.0), "rubber band ends on the anchor"); + ev(&mut s, PointerKind::Down, 169.0, 98.0); + ev(&mut s, PointerKind::Up, 169.0, 98.0); + ev(&mut s, PointerKind::Down, 136.0, 130.0); + ev(&mut s, PointerKind::Up, 136.0, 130.0); + let id = s.doc().unwrap().selection.objects[0].0; + assert_eq!(text(&s, id), "72.00 pt"); + run(&mut s, "select.set", json!({"ids":[line]})); + run(&mut s, "object.scale", json!({"sx":200,"sy":100})); + assert_eq!(text(&s, id), "144.00 pt", "linked to both anchors"); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 2cd39dfc6..aed41c829 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -133,6 +133,23 @@ fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option { (q.x.is_finite() && q.y.is_finite()).then_some(q) } +/// Where a Linear pick at `p` lands: on an anchor within reach (kept as a link to its path), +/// else on the closest point of the picked line, else on the usual snap targets or `p` itself. +fn snap_linear(cx: &ToolContext, p: Point) -> (Point, Option) { + let (mut at, _) = crate::guides::snap_pick(cx, p); + let mut reference = None; + if let Some(edge) = pick(cx, p) { + if let Some((anchor, point)) = anchor_near(cx, &edge, p) { + at = point; + reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); + } else if let Some(point) = nearest_on(cx, &edge, p) { + // Not on an anchor: snap to the closest point of the line (#2432). + at = point; + } + } + (at, reference) +} + impl DimensionTool { fn target(&self, cx: &ToolContext, p: Point) -> Option { let edge = pick(cx, p)?; @@ -228,17 +245,7 @@ impl DimensionTool { fn click(&mut self, cx: &ToolContext, p: Point, target: Option, mods: Mods) -> Vec { match self.kind { Kind::Linear => { - let (mut at, _) = crate::guides::snap_pick(cx, p); - let mut reference = None; - if let Some(edge) = pick(cx, p) { - if let Some((anchor, point)) = anchor_near(cx, &edge, p) { - at = point; - reference = Some(PointRef { node: edge.node, subpath: edge.subpath, anchor }); - } else if let Some(point) = nearest_on(cx, &edge, p) { - // Not on an anchor: snap to the closest point of the line (#2432). - at = point; - } - } + let (mut at, mut reference) = snap_linear(cx, p); if mods.shift && let Some((last, _)) = self.points.last() { @@ -418,8 +425,19 @@ impl Tool for DimensionTool { for edge in &self.lines { segment(edge, &mut out); } + // Linear: show where the next click lands (anchor ring, or a dot on a line), and draw the + // rubber band to that point, so the user sees the snap before clicking (#2516). + let linear_pick = self.kind == Kind::Linear && self.pending.is_none() && self.lines.is_empty(); + let snapped = linear_pick.then(|| snap_linear(cx, self.at)); + if let Some((q, reference)) = &snapped + && (reference.is_some() || q.distance(self.at) > 1e-9) + { + let on_anchor = reference.is_some(); + out.push(Overlay::Anchor { p: *q, color, filled: !on_anchor, size: if on_anchor { 10.0 } else { 6.0 } }); + } if let Some((p, _)) = self.points.last() { - let b = if self.shift { constrained(cx, *p, self.at) } else { self.at }; + let target = snapped.map_or(self.at, |(q, _)| q); + let b = if self.shift { constrained(cx, *p, target) } else { target }; out.push(Overlay::Line { a: *p, b, color, dashed: true }); } out From 8fead43ed23fe0b04e551f9edfb071b5d43c9916 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:53:22 +0700 Subject: [PATCH 055/184] Tests: only the new Inner Glow test lays a background under the pattern (#2426) --- crates/engine/src/tests_pattern.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 9aeff6c70..d19e2d8c2 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -115,8 +115,6 @@ fn edit_then_cancel_restores_definition() { fn edit_defaults_to_selected_objects_pattern() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); sel(&mut s, &[big]); @@ -131,8 +129,6 @@ fn edit_defaults_to_selected_objects_pattern() { fn set_fill_with_pattern_swatch() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots")); @@ -174,8 +170,6 @@ fn options_set_tile_type_spacing_and_overlap() { fn options_rename_updates_swatch_and_paints() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); @@ -188,8 +182,6 @@ fn options_rename_updates_swatch_and_paints() { fn delete_pattern_unpaints_objects() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); @@ -203,8 +195,6 @@ fn delete_pattern_unpaints_objects() { fn transform_patterns_only_moves_placement() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let before = node(&s, big).geometric_bounds(); @@ -222,8 +212,6 @@ fn transform_patterns_only_moves_placement() { fn transforms_without_transform_objects_move_only_the_pattern() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); s.execute("select.set", &json!({"ids": [big.0]})).unwrap(); @@ -308,8 +296,6 @@ fn hit_testing_in_edit_mode_only_reaches_tile_layer() { fn save_load_roundtrip_keeps_patterns_and_repeats() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let r = rect(&mut s, 300.0, 50.0, 10.0, 10.0); @@ -326,8 +312,6 @@ fn save_load_roundtrip_keeps_patterns_and_repeats() { fn svg_export_writes_pattern_def() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let svg = vectorcraft_svg::export(doc(&s), &vectorcraft_svg::ExportOptions::default()); @@ -345,8 +329,6 @@ fn svg_export_writes_pattern_def() { fn pdf_export_expands_pattern_fills() { let mut s = session(); make_dots(&mut s); - let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0); - s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap(); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let rep = vectorcraft_pdf::export_with_report(doc(&s), &Default::default()).unwrap(); From 0746519517493c426c77138b9bd8dd20597d5f70 Mon Sep 17 00:00:00 2001 From: EuanTop Date: Thu, 15 Oct 2026 23:51:50 +0800 Subject: [PATCH 056/184] Fix localized legacy font family name fallback --- crates/text/src/fontdb.rs | 7 ++----- crates/text/src/tests_sysfonts.rs | 32 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 5 +++-- docs/type-parity.md | 4 +++- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 96ef04656..c37ca8e9f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -521,11 +521,8 @@ fn pick_native_label(english: &str, pairs: &[(Option, String)]) -> Optio /// The family's native-language name from the font (typographic, else legacy), when it has one. fn native_family_name(font: &skrifa::FontRef<'_>, english: &str) -> Option { - let pairs = { - let t = localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME); - if t.is_empty() { localized(font, StringId::FAMILY_NAME) } else { t } - }; - pick_native_label(english, &pairs) + pick_native_label(english, &localized(font, StringId::TYPOGRAPHIC_FAMILY_NAME)) + .or_else(|| pick_native_label(english, &localized(font, StringId::FAMILY_NAME))) } /// Every localized string of `id`. diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index b9b5a1400..49721a2e2 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -642,6 +642,38 @@ fn family_display_name_follows_show_font_names_in_english() { assert_eq!(face.family, "Hiragino Sans"); } +/// #2208: English-only typographic names must not hide a localized legacy family name. +/// Cover both fonts added as bytes and the system-font catalog before a face is loaded. +#[test] +fn family_display_name_falls_back_to_localized_legacy_names() { + const ZH: u16 = 0x0804; + for (case, (typographic, legacy, expected)) in + [(None, Some("宋体"), "宋体"), (Some("排印宋体"), Some("宋体"), "排印宋体"), (None, None, "SimSun")].into_iter().enumerate() + { + let mut records = vec![(3, EN, 1, "SimSun"), (3, EN, 2, "Regular"), (3, EN, 6, "SimSun-Regular"), (3, EN, 16, "SimSun")]; + if let Some(label) = typographic { + records.push((3, ZH, 16, label)); + } + if let Some(label) = legacy { + records.push((3, ZH, 1, label)); + } + let bytes = named(&records); + let loaded = FontDb::with_font_dirs(vec![]); + assert!(loaded.add_font(bytes.clone()) > 0); + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-localized-family-{case}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("localized.ttf"), bytes).unwrap(); + let catalog = FontDb::with_font_dirs(vec![dir.clone()]); + for (source, db) in [("loaded", loaded), ("catalog", catalog)] { + assert_eq!(db.family_display_name("SimSun", true), "SimSun", "{source}, case {case}: English requested"); + assert_eq!(db.family_display_name("SimSun", false), expected, "{source}, case {case}: native requested"); + assert_eq!(db.family_display_name("SimSun", true), "SimSun", "{source}, case {case}: switch back to English"); + assert_eq!(db.is_loaded("SimSun"), source == "loaded", "{source}, case {case}: catalog names need no face loading"); + } + std::fs::remove_dir_all(dir).unwrap(); + } +} + /// Thai typed in a font without Thai takes an installed font made for Thai (Leelawadee UI, /// Thonburi, Noto Sans Thai…), whatever other face covering Thai loaded first; Latin beside it keeps /// the font chosen. Skipped where none of those families is installed. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9901ddb37..9162df6c9 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-10-16 · **Change:** minor (batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -77,7 +77,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated; Warp Options controls verified by focused checks and original-image review | | Raster effects (Effect Gallery) | 4 | 49% | 27 Photoshop-style filters (14 Artistic, 13 Sketch), the Effect Gallery dialog, Load Texture | 22–38 | measured count 30/57; texture redraw distances optimized (#1257), pixels verified; depth estimated | | 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 (other types use flat shading; one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, 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; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass | +| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, 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; 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) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | 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`) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | diff --git a/docs/type-parity.md b/docs/type-parity.md index ae3af354d..beb684425 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -18,6 +18,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Feature | Status | Notes | |---|---|---| +| Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands | | Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444) | @@ -59,6 +60,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Native font-family labels fall back from English-only typographic names to localized legacy names (#2208). English labels and native typographic precedence are preserved; synthetic loaded/catalog tests. Scores and estimates unchanged. | | 2026-10-16 | minor | Align Stroke on live type: the characters' stroke goes inside or outside the glyphs without creating outlines, for every run or a range; kept in the native file and in canvas, PDF, EPS and SVG output (#2294); beyond Illustrator, scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy (#1940); scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy; a double-click on area type's bottom middle handle turns on Auto Size (#1940); scores and hours unchanged | From d5561d6e02bea8d98bea7e3ab36942d97fac5f07 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:54:10 +0700 Subject: [PATCH 057/184] Live Corners: drop the single-corner radius limits nothing uses since #2332 --- crates/geom/src/corners.rs | 9 --------- crates/geom/src/shapes.rs | 8 -------- 2 files changed, 17 deletions(-) diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 16120aa0e..46449f43a 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -148,12 +148,6 @@ impl Corner { self.radius_at_setback(room(self.lu, before).min(room(self.lv, after))) } - /// The radius the corner is drawn with whatever its neighbours do: `radius`, no larger than - /// [`Corner::max_radius`], 0 when negative or not a number. - pub fn fitted(&self, radius: f64) -> f64 { - radius.max(0.0).min(self.max_radius()) - } - /// The largest radius the corner draws when it and the corners for which `edited` holds are /// set to one radius together, the other corners of `corners` keeping their `radii` (by /// anchor index). A side shared with an edited corner takes both cuts as far as they fit @@ -422,9 +416,6 @@ mod tests { for c in &corners { let max = c.max_radius(); assert!((c.setback(max) - c.lu.min(c.lv) / 2.0).abs() < 1e-9); - assert_eq!(c.fitted(1e6), max); - assert_eq!(c.fitted(-3.0), 0.0); - assert_eq!(c.fitted(f64::NAN), 0.0); } // The right angle at (0, 0) takes up to 20, half its shorter side. assert!((corners[0].max_radius() - 20.0).abs() < 1e-9); diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 54a7ad933..e677d34ff 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -52,13 +52,6 @@ pub fn max_corner_radius(w: f64, h: f64) -> f64 { w.abs().min(h.abs()) / 2.0 } -/// The radius a corner of a `w` × `h` rectangle is drawn with: `radius`, no larger than -/// [`max_corner_radius`] (the same limit for every corner, so opposite corners match), 0 when -/// negative or not a number. -pub fn fitted_corner_radius(w: f64, h: f64, radius: f64) -> f64 { - radius.max(0.0).min(max_corner_radius(w, h)) -} - /// The radii the corners of a `w` × `h` rectangle (top-left, top-right, bottom-right, /// bottom-left) are drawn with: each its own, no larger than leaves room for its neighbours' /// cuts ([`crate::corners::fitted_radii`]): half the shorter side when they are all as large, up @@ -409,7 +402,6 @@ mod tests { fn corners_of_a_non_square_rectangle_are_alike() { let r = Rect::new(0.0, 0.0, 200.0, 80.0); assert_eq!(max_corner_radius(-200.0, 80.0), 40.0); - assert_eq!([30.0, 60.0, -1.0, f64::NAN].map(|x| fitted_corner_radius(200.0, 80.0, x)), [30.0, 40.0, 0.0, 0.0]); for (radius, drawn) in [(30.0, 30.0), (60.0, 40.0)] { for kinds in [[CornerKind::Round; 4], [CornerKind::Chamfer, CornerKind::InvertedRound, CornerKind::Round, CornerKind::Chamfer]] { let a: Vec = rectangle_with_corners(r, [radius; 4], kinds).subpaths[0].anchors.iter().map(|a| a.p).collect(); From 4ed644d98d028fdb1dd64549968b02a6cdd48f26 Mon Sep 17 00:00:00 2001 From: Roman Vasilyev Date: Fri, 16 Oct 2026 16:58:07 -0700 Subject: [PATCH 058/184] EPS: preserve editable Illustrator art and filter raster page comparison --- contributors/people.toml | 3 + crates/eps/src/import/ai/mesh.rs | 20 ++++- crates/eps/src/import/ai/obj.rs | 2 + crates/eps/src/import/ai/read.rs | 91 ++++++++++++++++++--- crates/eps/src/import/ai/tests.rs | 75 +++++++++++++++++- crates/eps/src/import/native.rs | 109 ++++++++++++++++++++++++-- crates/eps/src/import/tests_native.rs | 48 ++++++++++++ docs/file-format-parity.md | 13 ++- docs/gaps.md | 5 +- docs/roadmap.md | 7 +- docs/target-app-parity.md | 5 +- 11 files changed, 347 insertions(+), 31 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index b30eb0fd2..0716520f9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -120,3 +120,6 @@ sync_github_name = false real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" sync_github_name = false + +[people.theromis] +sync_github_name = true diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 136818a4e..4866ffad1 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -96,8 +96,24 @@ impl MeshReader { let [n0, n1, n2, n3] = corners else { return None }; // (anchor, towards the next corner, towards the previous one, colour, opacity) let read = |n: &Vec| -> Option<(Point, Vec2, Vec2, Color, f32)> { - let comps = n.len().checked_sub(9)?; - let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None }; + // Older editing streams omit opacity; the colour space determines the number + // of colour components, so a coordinate must never be mistaken for opacity. + let comps = match self.space.as_str() { + "DeviceRGB" => 3, + "DeviceCMYK" => 4, + "DeviceGray" => 1, + _ => return None, + }; + let (opacity, geometry) = match n.get(comps..)? { + geometry if geometry.len() == 8 => (1.0, geometry), + [opacity, geometry @ ..] if geometry.len() == 8 => (*opacity, geometry), + _ => return None, + }; + let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None }; + // Non-unit rational weights need a different surface representation. + if nw != 1.0 || pw != 1.0 { + return None; + } if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) { return None; } diff --git a/crates/eps/src/import/ai/obj.rs b/crates/eps/src/import/ai/obj.rs index a6ae43c8a..d91f153a8 100644 --- a/crates/eps/src/import/ai/obj.rs +++ b/crates/eps/src/import/ai/obj.rs @@ -66,6 +66,8 @@ pub(super) struct Obj { pub entries: Vec<(Option, Vec)>, /// Values closed without a key. pub content: Vec, + /// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary. + pub art: Option>, } impl Obj { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index a4fc4eac2..95cad73c7 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -272,6 +272,7 @@ enum Kind { Group, Compound, Clip, + Mask, Obj(Box), } @@ -290,6 +291,8 @@ struct Frame { base: usize, /// The clipping paths' indexes among the children. clips: Vec, + /// A compound member group explicitly declares its subpaths' winding with `D`. + compound_members: bool, } /// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands @@ -383,6 +386,8 @@ struct Reader<'a> { clip_fills: HashSet, /// Groups closed as an object's drawn look (`9 () XW`). looks: HashSet, + /// Legacy compound member groups, even when followed by the generic `9 () XW` marker. + compound_groups: 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. @@ -425,9 +430,6 @@ const IGNORED: &[&str] = &[ "Xh", "XH", "XF", - "D", - "X=", - "X+", "Bc", "XP", "Np", @@ -529,6 +531,7 @@ impl<'a> Reader<'a> { hidden_ids: HashSet::new(), clip_fills: HashSet::new(), looks: HashSet::new(), + compound_groups: HashSet::new(), group_ended: false, layer_state: None, styles: HashMap::new(), @@ -785,7 +788,16 @@ impl<'a> Reader<'a> { self.ensure_space(); } let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) }; - self.frames.push(Frame { kind, children: Vec::new(), hidden, after: self.after_object, gs: self.gs.clone(), base, clips: Vec::new() }); + self.frames.push(Frame { + kind, + children: Vec::new(), + hidden, + after: self.after_object, + gs: self.gs.clone(), + base, + clips: Vec::new(), + compound_members: false, + }); self.after_object = false; Ok(()) } @@ -836,7 +848,7 @@ impl<'a> Reader<'a> { "*U" => self.close(what, |k| matches!(k, Kind::Compound))?, _ => self.close(what, |k| matches!(k, Kind::Clip))?, }; - let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f; + let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f; // A group holding only clipping paths (`q … u W n U … Q`) doesn't clip on its own: `u`/`U` don't save // the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it. if matches!(kind, Kind::Group) @@ -879,6 +891,9 @@ impl<'a> Reader<'a> { if clipping && let Some(f) = self.frames.last_mut() { f.clips.push(f.children.len()); } + if compound_members { + self.compound_groups.insert(node.id); + } self.add(node, opened_hidden || hidden)?; if let Some(p) = painted { self.clip_fills.insert(p.id); @@ -994,6 +1009,16 @@ impl<'a> Reader<'a> { return Ok(()); } ";" => return self.end_obj(), + "X=" => { + if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") { + // `X=` also introduces legacy text frame art, read by `text_frames`. + return Ok(()); + } + self.end_path_quietly(); + return self.open(Kind::Mask, hidden); + } + "X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(), + "X+" => return Ok(()), _ => {} } // What follows an object (its name, transparency and style) ends with anything else. The @@ -1105,6 +1130,19 @@ impl<'a> Reader<'a> { } } "d" => self.dash(&vals), + "D" => { + // The published format §6.8 permits `u` groups of compound members: + // https://www.moon-soft.com/program/FORMAT/graphics/ai30.pdf + // Their explicit winding declarations describe subpaths, not separate paints; + // modern streams can also end these groups with `9 () XW`. + let n = self.frames.len(); + if n >= 2 + && self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound)) + && let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group)) + { + f.compound_members = true; + } + } // Transparency and state. "Xy" => self.transparency(&vals), // A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as @@ -1224,6 +1262,23 @@ impl<'a> Reader<'a> { Ok(()) } + /// Capture mask art in document coordinates, without adding it to the owner's siblings. + fn end_mask(&mut self) -> Result<(), String> { + self.end_path_quietly(); + let f = self.close("X+", |k| matches!(k, Kind::Mask))?; + let (children, clip) = self.clip_first(f.children, &f.clips); + let art = self.new_node(NodeKind::Group { children, clip }, &f.gs); + self.gs = f.gs; + self.after_object = f.after; + let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else { + return Err("mask art ends outside a mask dictionary".into()); + }; + if o.art.replace(Arc::new(art)).is_some() { + return Err("a mask has more than one art block".into()); + } + Ok(()) + } + /// `;`: the innermost dictionary closes. fn end_obj(&mut self) -> Result<(), String> { if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { @@ -1379,13 +1434,19 @@ impl<'a> Reader<'a> { /// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the /// opacity mask it may carry. fn art_dictionary(&mut self, o: &Obj) { - // A mask (`/Mask`, keyed `AI9_Mask`): its art is on `X=` … `X+` lines, and drawing the - // object without it shows what the mask hides, so it must be named (#2004). A mask that - // is disabled changes nothing. - if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) - && mask.nums("Disabled").first().copied() != Some(1.0) - { - self.unreadable("opacity masks"); + if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) { + if let Some(art) = &mask.art { + let on = |key: &str| mask.nums(key).first().copied() == Some(1.0); + let value = + OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") }; + if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) { + n.extras_mut().mask = Some(Box::new(value)); + } else if !on("Disabled") { + self.unreadable("opacity masks without an owning object"); + } + } else if mask.nums("Disabled").first().copied() != Some(1.0) { + self.unreadable("opacity masks without readable art"); + } } let name = match o.text("AIArtName") { Some(name) => name, @@ -1542,7 +1603,11 @@ impl<'a> Reader<'a> { let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); if code == 9.0 - && let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. })) + && let Some(look) = self + .frames + .last() + .and_then(|f| f.children.last()) + .filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id)) { self.looks.insert(look.id); } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index d903e4d05..404c216db 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -809,8 +809,7 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() { assert!(e.contains("pattern fills"), "{e}"); let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err(); assert!(e.contains("symbols"), "{e}"); - // An opacity mask (#2004): drawing the layer's art whole would show what the mask hides, so - // the mask is named and the file is read from its PDF part instead. + // A mask with no owner must still be reported (#2004). let mask = "%_/ArtDictionary :\n%_/XMLUID : (M) ; (AI10_ArtUID) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\ %_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n"; let e = read(stream(&layer(mask)).as_bytes()).unwrap_err(); @@ -1219,3 +1218,75 @@ fn a_gradient_mesh_reads_as_a_patch() { let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err(); assert!(e.contains("gradient meshes"), "{e}"); } + +/// Both mesh-corner layouts must keep colour, position and handles, rather than guessing +/// opacity from a coordinate. Unsupported weights and truncated corners remain errors. +#[test] +fn mesh_corners_with_and_without_opacity() { + let geometry = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"]; + for (space, colour) in [("DeviceRGB", "0.2 0.4 0.6"), ("DeviceCMYK", "0.1 0.2 0.3 0.4"), ("DeviceGray", "0.5")] { + let mesh = |opacity: &str| { + let corners: String = geometry.iter().map(|g| format!("%_[{colour} {opacity}{g}] /N X#\n")).collect(); + format!("/Mesh X!\n%_/{space} /CS X#\n{corners}%_/E X#\n/End X!\n") + }; + let old = art(&read_ok(&layer(&mesh(""))).doc); + let new = art(&read_ok(&layer(&mesh("1 "))).doc); + assert_eq!(old[0].kind, new[0].kind); + let translucent = art(&read_ok(&layer(&mesh("0.25 "))).doc); + let NodeKind::Mesh(m) = &translucent[0].kind else { panic!("mesh") }; + assert!(m.points.iter().all(|p| p.opacity == 0.25)); + assert_eq!(m.points[0].p, Point::new(10.0, 80.0)); + for broken in [mesh("").replace("10 25 1", "10 25 0.5"), mesh("").replace("15 20 1]", "15 20]")] { + assert!(read(stream(&layer(&broken)).as_bytes()).unwrap_err().contains("gradient meshes")); + } + } +} + +#[test] +fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() { + let owner = format!("{RED}{}f\n", rect_path(10.0, 20.0, 50.0, 60.0)); + let mask = "%_/ArtDictionary :\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 60 L\n%_30 60 L\n%_30 20 L\n%_f\n%_U\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let following = format!("{}f\n", rect_path(70.0, 20.0, 80.0, 30.0)); + for (invert, disabled, linked) in [(false, false, true), (true, false, false), (false, true, true)] { + let mask = mask + .replace("0 /Bool (Inverted)", &format!("{} /Bool (Inverted)", u8::from(invert))) + .replace("0 /Bool (Disabled)", &format!("{} /Bool (Disabled)", u8::from(disabled))) + .replace("1 /Bool (Linked)", &format!("{} /Bool (Linked)", u8::from(linked))); + let r = read_ok(&layer(&format!("{owner}{mask}{following}"))); + assert!(r.warnings.is_empty()); + let a = art(&r.doc); + assert_eq!(a.len(), 2); + assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "mask colour must not leak"); + let m = a[0].extras().mask.as_ref().unwrap(); + assert_eq!((m.clip, m.invert, m.disabled, m.linked), (true, invert, disabled, linked)); + let mask_path = &m.art.children().unwrap()[0].children().unwrap()[0]; + assert!(matches!(mask_path.kind, NodeKind::Path { .. })); + assert_eq!(mask_path.geometric_bounds(), Some(Rect::new(10.0, 40.0, 30.0, 80.0))); + assert_ne!(mask_path.id, a[0].id); + } + for broken in [mask.replace("%_X+\n", ""), mask.replace("%_U\n", ""), mask.replace("%_f", "%_unknownMaskOperator")] { + assert!(read(stream(&layer(&format!("{owner}{broken}"))).as_bytes()).is_err()); + } +} + +/// `9 () XW` also follows legacy compound member groups. The final gradient paints +/// every member; the initial black paint is a placeholder, not a separate black object. +#[test] +fn winding_declared_compound_members_keep_the_final_gradient() { + let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n0 0 0 0 1 0 0 2 50 0 Bs\n0 0 0 0 0 0 1 2 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let body = format!( + "*u\nu\n1 D\n0 g\n{}f\n0 D\n{}Bb\n1 (G) 0 0 0 100 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n9 () XW\n*U\n", + rect_path(10.0, 10.0, 80.0, 80.0), + rect_path(30.0, 30.0, 50.0, 50.0) + ); + let r = read_ok(&format!("{defs}{}", layer(&body))); + let a = art(&r.doc); + assert_eq!(a.len(), 1); + let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("compound, not separately painted look") }; + assert_eq!(children.len(), 2); + assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.gradient.stops.len() == 2)); + assert!(children.iter().all(|n| matches!(n.kind, NodeKind::Path { .. }))); + // Without winding declarations this is still an independently painted appearance group. + let r = read_ok(&format!("{defs}{}", layer(&body.replace("1 D\n", "").replace("0 D\n", "")))); + assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. })); +} diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 465cec773..4564ff1ad 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -703,6 +703,44 @@ fn compare_scale(r: Rect) -> f64 { (400.0 / r.width().max(r.height()).max(1.0)).min(4.0) } +/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases +/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher +/// resolution and apply the same reconstruction filter; keep the existing difference limits. +fn comparison_samples(doc: &Document) -> u32 { + let (mut images, mut vector) = (false, false); + doc.walk(|n| match n.kind { + NodeKind::Image(_) => images = true, + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + _ => vector = true, + }); + if images && !vector { 4 } else { 1 } +} + +/// Render for the page check. The comparison scale bounds the long side to 400 px, so the +/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha. +fn comparison_render( + renderer: &mut vectorcraft_render::Renderer, + doc: &Document, + rect: Rect, + scale: f64, + samples: u32, +) -> vectorcraft_render::Rendered { + let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false); + if samples == 1 { + return rendered; + } + let (width, height) = match vectorcraft_render::raster_size(rect, scale) { + Ok(size) => size, + // A malformed region cannot provide a comparable raster. + Err(_) => return rendered, + }; + let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else { + return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() }; + }; + let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw(); + vectorcraft_render::Rendered { width, height, pixels } +} + /// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers /// with a renderer of their own, which keeps their decoded images between draws (the page's images /// can share their keys and not their pixels, so the two don't share one). @@ -712,6 +750,7 @@ struct PageView { page_type: Vec, layers: vectorcraft_render::Renderer, scale: f64, + samples: u32, } impl PageView { @@ -720,10 +759,11 @@ impl PageView { let scale = compare_scale(ra); // Drawn on nothing and then seen on white: blending modes have no page to blend with, as in // the app that wrote the file. - let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false); + let samples = comparison_samples(frame.page); + let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); let mut page_type = vec![]; type_areas(&frame.page.layers, ra, scale, &mut page_type); - Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale } + Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples } } /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). @@ -731,7 +771,7 @@ impl PageView { // The page doesn't print what isn't printed. let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); - let y = self.layers.render_region(b, rb, self.scale, false); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); let mut areas = self.page_type.clone(); type_areas(&b.layers, rb, self.scale, &mut areas); compared(&self.page, &y, &areas) @@ -743,7 +783,7 @@ impl PageView { fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 { let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); - let y = self.layers.render_region(b, rb, self.scale, false); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); if (y.width, y.height) != (self.page.width, self.page.height) { return 1.0; } @@ -759,8 +799,9 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { let b = printed.as_ref().unwrap_or(b); let scale = compare_scale(ra); // A renderer each: the two documents' images can share their keys and not their pixels. - let x = vectorcraft_render::Renderer::new().render_region(a, ra, scale, false); - let y = vectorcraft_render::Renderer::new().render_region(b, rb, scale, false); + let samples = comparison_samples(a); + let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples); + let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples); let mut areas = vec![]; type_areas(&a.layers, ra, scale, &mut areas); type_areas(&b.layers, rb, scale, &mut areas); @@ -772,7 +813,7 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { /// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, /// and what the comparison looks for is art that is missing or out of place. fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference { - if x.width != y.width || x.height != y.height { + if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { return Difference { any: 1.0, solid: 1.0 }; } let differs: Vec = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect(); @@ -1358,4 +1399,58 @@ mod tests { let texts: Vec = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); assert_eq!(texts, ["Hello", "abcd"]); } + /// A flattened page samples a fine stripe pattern once per comparison pixel; that + /// aliases to white although vector coverage is gray. Shared minification avoids the + /// false fallback, while genuinely missing art must still be rejected. + #[test] + fn raster_page_comparison_filters_minification_without_accepting_missing_art() { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Appearance, ImageBlob, ImageObject}; + let mut vector = Document::new(1000.0, 500.0); + let layer = vector.layers[0].id; + for i in 0..400 { + let x = f64::from(i) * 2.5 + 0.5; + let n = Node::path( + vector.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 1.0, 500.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + vector.insert(Some(layer), usize::MAX, n).unwrap(); + } + let rect = vector.artboards[0].rect; + let source = vectorcraft_render::Renderer::new().render_region(&vector, rect, 1.6, true); + let mut png = Vec::new(); + image::RgbaImage::from_raw(source.width, source.height, source.to_straight()) + .unwrap() + .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(); + let mut page = Document::new(1000.0, 500.0); + page.images.insert(key.clone(), blob); + let image = Node::new( + page.alloc_id(), + NodeKind::Image(Box::new(ImageObject { + key, + width: source.width, + height: source.height, + xf: Affine::scale(1.0 / 1.6), + link: None, + placement: Default::default(), + })), + ); + page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap(); + assert_eq!(comparison_samples(&page), 4); + assert_eq!(comparison_samples(&vector), 1, "ordinary vector pages retain their comparison"); + let mut renderer = vectorcraft_render::Renderer::new(); + let a = comparison_render(&mut renderer, &page, rect, 0.4, 1); + let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1); + assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false }; + let mut view = PageView::new(&frame); + assert!(compare(&frame, &mut view, &vector).is_ok()); + assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0)).is_err(), "missing artwork remains an error"); + let shifted = Rect::new(0.0, 0.0, 500.0, 500.0); + assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error"); + } } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index b7f9a67fe..7406ca3ee 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -994,3 +994,51 @@ fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() { let b = frame.bounds().unwrap(); assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}"); } + +/// A raster print page can carry a fully vector editing copy. The legacy mesh has no +/// opacity operand and its vector mask hides half of it: ignoring either loses this import. +#[test] +fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() { + let mesh = "/Mesh X!\n%_/DeviceRGB /CS X#\n\ + %_[0 0 1 20 20 20 30 1 30 20 1] /N X#\n\ + %_[0 0 1 20 60 30 60 1 20 50 1] /N X#\n\ + %_[0 0 1 60 60 60 50 1 50 60 1] /N X#\n\ + %_[0 0 1 60 20 50 20 1 60 30 1] /N X#\n%_/E X#\n/End X!\n"; + let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_1 g\n%_20 20 m\n%_20 60 L\n%_40 60 L\n%_40 20 L\n%_f\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n"; + let curve = "0 1 1 0 1 0 0 Xa\n70 20 m\n70 50 90 50 90 20 c\nf\n"; + let data = native(&layer("Editable", true, &format!("u\n{mesh}{mask}{curve}U\n"))); + let samples = ("0000ff".repeat(20) + &"ffffff".repeat(20)).repeat(40); + let page = format!( + "gsave 20 20 translate 40 40 scale /DeviceRGB setcolorspace \ + << /ImageType 1 /Width 40 /Height 40 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] \ + /ImageMatrix [40 0 0 -40 0 40] /DataSource <{samples}> >> image grestore \ + 1 0 0 setrgbcolor 70 20 moveto 70 50 90 50 90 20 curveto closepath fill" + ); + // Use AI9's zlib container, too, rather than only the newer Zstandard test helper. + use std::io::Write as _; + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(data.as_bytes()).unwrap(); + let packed = ascii85(&encoder.finish().unwrap()); + let file = format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n%{packed}\n%AI9_PrivateDataEnd\n" + ); + let r = import(file.as_bytes()).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(names(&r.document), ["Editable"]); + let group = &r.document.layers[0].children().unwrap()[0]; + let a = group.children().unwrap(); + assert_eq!(a.len(), 2); + assert!(matches!(a[0].kind, NodeKind::Mesh(_))); + assert!(a[0].extras().mask.is_some()); + let NodeKind::Path { path, .. } = &a[1].kind else { panic!("editable path") }; + assert_eq!(path.anchor_count(), 2); + assert_ne!(path.subpaths[0].anchors[0].h_out, path.subpaths[0].anchors[0].p); + assert_ne!(a[0].id, a[1].id); + // The normal EPS path remains available explicitly. + let page = crate::import_with(file.as_bytes(), false).unwrap(); + assert!(matches!(page.document.layers[0].children().unwrap()[0].kind, NodeKind::Image(_))); + // A wrong editing copy must still fail the page comparison. + let wrong = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 120 100"); + let r = import(wrong.as_bytes()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d0010471b..7b6fb8b40 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; batch exporting, #1935; legacy `.ai` runs as PostScript with a prolog of its own, #868; legacy `.ai` of version 7 saved, with type as text, hidden objects and an artboard picker, and its type read, #868; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public @@ -22,6 +22,16 @@ from the public specifications, and users' own `.ai` files are used locally only | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | +EPS editing-data follow-up (2026-10-16): both mesh-corner layouts (with and without opacity) +read in RGB, CMYK and gray. Vector opacity-mask art and its Clip, Invert, Disabled and Linked +flags are retained. Winding-declared legacy compound member groups keep their shared paint even +when followed by `9 () XW`. Fully rasterized print pages are compared using the same filtered +minification for both representations; the existing error thresholds and ordinary vector-page +checks remain. Synthetic regressions reject truncated meshes, non-unit rational weights, +unbalanced mask art, unknown mask operators, missing artwork and wrong page framing. Real +user files remain local; no SVG replacement or tracing is used. Small rendering differences +continue to produce import warnings. + ## The main format: `.ai` The format that decides beta. Illustrator's `.ai` is a PDF (or, for old files, PostScript) plus Illustrator's @@ -113,6 +123,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | `.ai` editing data: a rotated embedded image lands where its raster matrix puts it, not turned 180° around its corner; the legacy version 7 writer and its prolog write and draw rotated images with the same matrix (#2269). Scores unchanged | | 2026-10-14 | minor | Legacy `.ai` of version 7 runs as a PostScript program: its prolog is a procedure set of VectorCraft's own that gives each operator the writer uses its meaning (paths, colours, compound paths, clipping groups, layers that don't print, images; gradients flat, type not set), and the page trailer's `annotatepage` (§3 of the specification) marks nothing; every file the writer's tests write is run through VectorCraft's PostScript reader and must draw what the document it was written from draws (#868) | diff --git a/docs/gaps.md b/docs/gaps.md index 65d067ccf..132c865f4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -87,6 +87,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Done (2026-10-16):** EPS legacy mesh corners and vector opacity masks retained; winding-declared compound member groups keep shared paint. Raster print-page minification no longer causes a false editing-data fallback; comparison limits remain unchanged. Synthetic regressions cover damage and missing artwork. This narrows G9; scores and estimates unchanged. + - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), guides and non-printing construction lines (#779), open speed on large files (#758), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025, placed (linked) files since #1264). Writing Illustrator's own editing data is out of scope @@ -333,6 +335,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e0fe3b9a..dcaf625a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: M12's Image Trace quick presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; M2: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; legacy `.ai` of version 7 saved, #868; estimates unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -24,7 +24,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | ## Current focus @@ -53,7 +53,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (closed SPLINE flags round-trip, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; auto/slider/advanced UI remains open); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 | @@ -84,6 +84,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to M12: quick Image Trace presets trace at once; the Image Trace button opens the adjustment controls (). Wording only; status unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-15 | minor | Image Trace retains nonzero positioned boundaries when a fixed-end returning piece has fewer than two fitted cubics. Source-generated tests check survival, winding, shared-edge reversal and Noise controls; see `image-trace-quality.md`. Scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9162df6c9..b35db0fb8 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-10-16 · **Change:** minor (native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -83,7 +83,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 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`) | 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 panel row context menus and Collect for Export (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 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md) | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | | 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged | From 8b8c164e3fbe31e40d25293676f2417e3b5389f0 Mon Sep 17 00:00:00 2001 From: Roman Vasilyev Date: Fri, 16 Oct 2026 18:42:56 -0700 Subject: [PATCH 059/184] CI: simplify boolean guards and translate EPS mask errors --- crates/engine/src/cmd/object.rs | 2 +- crates/render/src/fx.rs | 2 +- crates/trace/tests/shared_boundaries.rs | 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/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.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/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ 19 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bbd159151..bd71c073e 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -459,7 +459,7 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo if let Some(k) = &it.copies && Arc::ptr_eq(&k.from, &it.doc) && k.roots == roots - && (!k.selection.guides.is_empty() == has_guide_copies) + && (k.selection.guides.is_empty() != has_guide_copies) { return Ok(Some(k.clone())); } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f5281fb47..3082d49aa 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -879,7 +879,7 @@ impl Renderer { off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); - if !self.shadows.get(&old).is_some_and(|e| silhouette(&pm, colored) == e.made.pixels) { + if self.shadows.get(&old).is_none_or(|e| silhouette(&pm, colored) != e.made.pixels) { return; } if let Some(mut e) = self.shadows.remove(&old) { diff --git a/crates/trace/tests/shared_boundaries.rs b/crates/trace/tests/shared_boundaries.rs index 926ecd067..9210c5a80 100644 --- a/crates/trace/tests/shared_boundaries.rs +++ b/crates/trace/tests/shared_boundaries.rs @@ -270,7 +270,7 @@ fn variants(art: &Art, n: usize, soft: bool, settings: &[Setting]) -> Vec Date: Fri, 16 Oct 2026 22:41:56 +0200 Subject: [PATCH 060/184] MCP: cancel long exports, answer while one runs, report progress (#2165, #2171, #2172) The MCP server read one line, ran it and answered it before reading the next, so notifications/cancelled was never seen during a call and every request (even ping) waited behind a running export. - vectorcraft_doc::cancel: a thread-local cancel token and progress sink that long work polls. Checkpoints: PDF pages, PDF flattening, geometry bake, raster-effect flattening, drawing objects, raster artboards, and before any file is written. A cancelled command returns EngineError::Cancelled and changes nothing. - Server::serve reads on its own thread: notifications/cancelled drops a queued request or stops the running one (no reply, as the MCP spec says), engine-free requests (ping, the list methods, prompts/get) are answered at once, a request that would wait behind a call running for 2 s is refused with -32003 naming that call, and _meta.progressToken gets notifications/progress (page n of m). Queue and output are bounded. - The remote backend stops waiting for the app when the call is cancelled. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-cli/src/main.rs | 4 +- crates/doc/src/cancel.rs | 142 ++++++++++ crates/doc/src/lib.rs | 1 + crates/effects/src/bake.rs | 3 +- crates/engine/src/cmd/fileio/encode.rs | 7 +- crates/engine/src/cmd/fileio/mod.rs | 3 + crates/engine/src/cmd/fileio/pdf.rs | 5 + crates/engine/src/cmd/flatten.rs | 5 + crates/engine/src/cmd/rasterfx.rs | 6 + crates/engine/src/lib.rs | 12 + crates/engine/src/tests_cancel.rs | 90 ++++++ crates/mcp/src/backend.rs | 37 +++ crates/mcp/src/headless.rs | 2 + crates/mcp/src/intake.rs | 309 +++++++++++++++++++++ crates/mcp/src/lib.rs | 3 + crates/mcp/src/server.rs | 281 ++++++++++++++----- crates/mcp/src/tests_concurrency.rs | 368 +++++++++++++++++++++++++ crates/mcp/tests/protocol_props.rs | 61 ++++ crates/pdf/src/export.rs | 10 +- crates/pdf/src/lib.rs | 3 + crates/render/src/lib.rs | 4 + docs/gaps.md | 4 +- docs/mcp.md | 57 ++-- docs/target-app-parity.md | 5 +- 24 files changed, 1328 insertions(+), 94 deletions(-) create mode 100644 crates/doc/src/cancel.rs create mode 100644 crates/engine/src/tests_cancel.rs create mode 100644 crates/mcp/src/intake.rs create mode 100644 crates/mcp/src/tests_concurrency.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index bb3879b47..b843af67f 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -213,8 +213,8 @@ fn mcp(args: &[String]) -> Result<(), String> { // uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel. vectorcraft_mcp::logging::install(); let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) + // The server reads and writes from threads of its own, so it takes the handles, not locks held here. + Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) } fn commands() -> Result<(), String> { diff --git a/crates/doc/src/cancel.rs b/crates/doc/src/cancel.rs new file mode 100644 index 000000000..bff510707 --- /dev/null +++ b/crates/doc/src/cancel.rs @@ -0,0 +1,142 @@ +//! Cooperative cancellation and progress for long-running work (exports, flattening, baking). +//! +//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`) +//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the +//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where +//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The +//! token is tied to the thread that entered the scope, so concurrent sessions on other threads +//! (and tests running in parallel) never see each other's cancellation, and code outside any +//! scope is never cancelled. +//! +//! Work that stops early leaves nothing behind: callers drop whatever was half built and report +//! the error, so a cancelled command changes nothing. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// A flag one thread raises and another polls. Cloning shares the flag. +#[derive(Clone, Debug, Default)] +pub struct CancelToken(Arc); + +impl CancelToken { + pub fn new() -> Self { + Self::default() + } + + /// Ask for the work holding this token to stop. + pub fn cancel(&self) { + self.0.store(true, Ordering::Relaxed); + } + + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Relaxed) + } +} + +/// The error of work that stopped because it was cancelled. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl std::fmt::Display for Cancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("cancelled") + } +} + +impl std::error::Error for Cancelled {} + +/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known. +pub type ProgressSink = Rc; + +struct Scope { + token: CancelToken, + sink: Option, +} + +thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; +} + +/// Puts the previous scope back, also when the work inside unwinds. +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + // `try_with`: during thread teardown the slot may be gone; nothing to restore then. + let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous); + } +} + +/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread. +pub fn scope(token: &CancelToken, sink: Option, f: impl FnOnce() -> T) -> T { + let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink })); + let _restore = Restore(previous); + f() +} + +/// Has the work running on this thread been cancelled? Always false outside a [`scope`]. +pub fn is_cancelled() -> bool { + CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled())) +} + +/// Is this thread running inside a [`scope`], so that a cancel can reach it? +pub fn is_cancellable() -> bool { + CURRENT.with(|c| c.borrow().is_some()) +} + +/// `Err(Cancelled)` when the work running on this thread has been cancelled. +pub fn check() -> Result<(), Cancelled> { + if is_cancelled() { Err(Cancelled) } else { Ok(()) } +} + +/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`] +/// that has a sink. +pub fn progress(done: u64, total: u64, what: &str) { + // The sink runs outside the borrow, so it may itself call back into this module. + let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone())); + if let Some(sink) = sink { + sink(done, total, what); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_work_inside_the_scope_is_cancelled() { + let t = CancelToken::new(); + assert_eq!(check(), Ok(())); + scope(&t, None, || { + assert_eq!(check(), Ok(())); + t.cancel(); + assert_eq!(check(), Err(Cancelled)); + // A nested scope with its own token shadows, then restores. + scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(()))); + assert!(is_cancelled()); + }); + assert!(!is_cancelled(), "the scope ended"); + } + + #[test] + fn progress_reaches_the_sink_and_the_scope_survives_a_panic() { + let seen = Rc::new(RefCell::new(vec![])); + let sink: ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let t = CancelToken::new(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + scope(&t, Some(sink), || { + progress(1, 3, "page"); + panic!("boom"); + }) + })); + assert!(r.is_err()); + progress(2, 3, "after"); + assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a11f04725..3b80bf692 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ pub mod appearance; pub mod assets; pub mod blend; +pub mod cancel; pub mod clipnest; pub mod cmyk; pub mod corners; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 0f5b89d1c..8c1b18d5e 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -89,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { - if !needs_bake(n) { + // A cancelled export leaves the rest of the document as it is: the caller drops the result. + if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index f37521efa..d28254f88 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -413,6 +413,8 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; let failed = vectorcraft_render::decode_failures(); let mut enc = encode_files(doc, f, p)?; + // Baking, flattening and drawing stop early when cancelled and leave a truncated result. + vectorcraft_doc::cancel::check()?; // An image the renderer couldn't decode draws nothing: say so rather than write a picture // that silently lacks it. if f.raster && vectorcraft_render::decode_failures() > failed { @@ -502,7 +504,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { }; let mut enc = Encoded::default(); let mut renderer = vectorcraft_render::Renderer::new(); - for b in chosen { + let total = chosen.len() as u64; + for (done, b) in chosen.into_iter().enumerate() { + vectorcraft_doc::cancel::check()?; + vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1)); let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect; check_format_size(f, region.width() * scale, region.height() * scale)?; vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b20e0db25..abdadb361 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -861,6 +861,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result> { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + // Work that stops early for a cancel can leave a truncated result: none of it is written. + vectorcraft_doc::cancel::check()?; may_write(path)?; // Never a half-written file: see [`write_atomic`]. write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) @@ -869,6 +871,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { /// [`write_file`] with what `fill` writes into the file (a save streamed into it). #[cfg(not(target_arch = "wasm32"))] pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; may_write(path)?; vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1703bece0..4af802a0a 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) { pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError { match e { PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()), + PdfError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), } } @@ -273,7 +274,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl } } let set = &opts.settings; + if set.pdf13() { + vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency"); + } let flat = flatten_for(cmd, doc, set, p)?; + vectorcraft_doc::cancel::check()?; if flat.is_some() { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 104af70d2..842293892 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -818,7 +818,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R let see_through: Vec = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect(); let cmyk = src.color_mode == ColorMode::Cmyk; let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; + // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). + vectorcraft_doc::cancel::check()?; for g in overlapping(&reaches) { + vectorcraft_doc::cancel::check()?; let ids: Vec = g.iter().map(|&i| roots[i]).collect(); let group = g.iter().any(|&i| see_through[i]).then(|| { let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); @@ -950,6 +953,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio ); let mut r = vectorcraft_render::Renderer::new(); let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha); + // The drawing stops early when cancelled: this image is not whole. + vectorcraft_doc::cancel::check()?; if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha { // Without transparency the clip alone shapes the image: it is opaque, over white (its // edge pixels too). diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 3bab565d0..554afb881 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -440,6 +440,10 @@ fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) { /// has none. An object whose effects all paint below it keeps its vector art over an image of /// them; one whose effects change it (blur, feather, inner glow) becomes the image. fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option { + // A cancelled export leaves the rest of the document as it is: the caller drops the result. + if vectorcraft_doc::cancel::is_cancelled() { + return None; + } let checkpoint = report.checkpoint(); let result = walk_inner(out, n, report); report.attribute_since(checkpoint, n); @@ -558,6 +562,8 @@ pub fn export_pdf_with_report( ) -> std::result::Result { let mut report = FlattenReport::new(Which::All, doc); let flat = flatten(doc, &mut report); + // Flattening stops early when cancelled, leaving a half-flattened copy that must not be written. + vectorcraft_doc::cancel::check().map_err(|_| vectorcraft_pdf::PdfError::Cancelled)?; let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?; exported.warnings.extend(report.warnings()); Ok(exported) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 395a83910..30ffda9f4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use journal::Journal; pub use tooling::{UiRequest, ViewInfo}; pub use vectorcraft_doc as doc; +pub use vectorcraft_doc::cancel; pub use vectorcraft_tools as tools; #[derive(Debug, thiserror::Error)] @@ -50,11 +51,20 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// The caller asked for the command to stop ([`cancel`]). It changed nothing. + #[error("cancelled; nothing was changed")] + Cancelled, /// A bug: the command panicked. The document was rolled back to its state before the command. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, } +impl From for EngineError { + fn from(_: cancel::Cancelled) -> Self { + EngineError::Cancelled + } +} + impl From for EngineError { fn from(e: vectorcraft_doc::DocError) -> Self { EngineError::Other(e.to_string()) @@ -1892,6 +1902,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_cancel; +#[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; diff --git a/crates/engine/src/tests_cancel.rs b/crates/engine/src/tests_cancel.rs new file mode 100644 index 000000000..8237707a1 --- /dev/null +++ b/crates/engine/src/tests_cancel.rs @@ -0,0 +1,90 @@ +//! Cancelling a long export (#2165): it stops at the next checkpoint with `EngineError::Cancelled`, +//! writes nothing, leaves the document as it was, and the session goes on working. + +use std::cell::Cell; +use std::rc::Rc; + +use serde_json::json; + +use crate::cancel::{self, CancelToken, ProgressSink}; +use crate::{EngineError, Session}; + +fn session(artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s +} + +fn revision(s: &Session) -> u64 { + s.doc().unwrap().revision +} + +#[test] +fn a_pdf_export_cancelled_between_pages_stops_writes_nothing_and_changes_nothing() { + let path = vectorcraft_testkit::temp_dir("cancel-pdf").join("out.pdf"); + std::fs::remove_file(&path).ok(); + let mut s = session(5); + let before = (revision(&s), s.doc().unwrap().history.undo.len()); + let token = CancelToken::new(); + let pages = Rc::new(Cell::new(0)); + // The sink runs inside the export, as the page loop reports each page: cancel on the third. + let sink: ProgressSink = { + let (token, pages) = (token.clone(), pages.clone()); + Rc::new(move |done, total, _| { + // Stages before the pages report without a total. + if total != 5 { + return; + } + pages.set(done); + if done == 3 { + token.cancel(); + } + }) + }; + let params = json!({"path": path.to_str().unwrap()}); + let r = cancel::scope(&token, Some(sink), || s.execute("document.exportPdf", ¶ms)); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert_eq!(pages.get(), 3, "the export stopped at the checkpoint after the cancel, not at the end"); + assert!(!path.exists(), "a cancelled export writes no file"); + assert_eq!((revision(&s), s.doc().unwrap().history.undo.len()), before, "the document is as it was"); + // The session is fine: the same export, not cancelled, completes. + s.execute("document.exportPdf", ¶ms).unwrap(); + assert!(path.exists()); +} + +#[test] +fn a_cancelled_flattening_export_stops_before_the_pages() { + let mut s = session(2); + let token = CancelToken::new(); + token.cancel(); + // PDF/X-1a flattens transparency first; a token raised beforehand stops at the first check. + let r = cancel::scope(&token, None, || s.execute("document.exportPdf", &json!({"standard": "pdfX1a"}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert!(s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).is_ok()); +} + +#[test] +fn raster_exports_and_svg_stop_when_cancelled_too() { + let dir = vectorcraft_testkit::temp_dir("cancel-formats"); + let mut s = session(3); + let token = CancelToken::new(); + token.cancel(); + for ext in ["png", "svg", "pdf"] { + let path = dir.join(format!("out.{ext}")); + std::fs::remove_file(&path).ok(); + let params = json!({"path": path.to_str().unwrap()}); + let r = cancel::scope(&token, None, || s.execute("document.export", ¶ms)); + assert!(matches!(r, Err(EngineError::Cancelled)), "{ext}: {r:?}"); + assert!(!path.exists(), "{ext}: nothing is written"); + } +} + +#[test] +fn nothing_is_cancelled_outside_a_scope() { + let token = CancelToken::new(); + token.cancel(); + // The token only counts for work run under it, on its thread. + let mut s = session(1); + assert!(s.execute("document.exportPdf", &json!({})).is_ok()); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index b3a00604a..97c990b88 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -85,6 +85,42 @@ impl Remote { Ok(()) } + /// Wait until the app starts to answer, giving up when the call is cancelled + /// ([`vectorcraft_engine::cancel`]): the app has no way to stop what it runs, but this server + /// stops waiting for it (the connection is dropped, so a late answer can't be mistaken for + /// the next call's). Without a cancellable call this is just the read the caller does next. + fn wait_for_reply(stream: &TcpStream) -> std::io::Result<()> { + const POLL: Duration = Duration::from_millis(100); + const LIMIT: Duration = Duration::from_secs(90); + if !vectorcraft_engine::cancel::is_cancellable() { + return Ok(()); + } + let start = std::time::Instant::now(); + let kept = stream.read_timeout()?; + stream.set_read_timeout(Some(POLL))?; + let outcome = loop { + match stream.peek(&mut [0]) { + // Bytes (or the end of the stream) to read now. + Ok(_) => break Ok(()), + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + if vectorcraft_engine::cancel::is_cancelled() { + break Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled; the app may still finish what it was asked, which can't be stopped from here", + )); + } + if start.elapsed() >= LIMIT { + break Err(e); + } + } + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => break Err(e), + } + }; + stream.set_read_timeout(kept)?; + outcome + } + fn roundtrip(&mut self, line: &str) -> std::io::Result { let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app")); @@ -92,6 +128,7 @@ impl Remote { writer.write_all(line.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; + Self::wait_for_reply(writer)?; let mut buf = Vec::new(); if !crate::server::read_line_capped(reader, &mut buf, MAX_REPLY_BYTES)? { return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d11e5eeb0..9442339e5 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -253,6 +253,8 @@ impl Headless { let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); vectorcraft_render::raster_size(r, scale)?; let img = self.renderer.render_region(&doc, r, scale, true); + // A cancelled render stops drawing: the picture is not whole and is not written. + vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?; let png = img.to_png()?; let mut out = json!({"width": img.width, "height": img.height}); match s(p, "path") { diff --git a/crates/mcp/src/intake.rs b/crates/mcp/src/intake.rs new file mode 100644 index 000000000..186bdbfd8 --- /dev/null +++ b/crates/mcp/src/intake.rs @@ -0,0 +1,309 @@ +//! What lets the server hear a client while a call runs: a reader thread that takes lines off the +//! transport, a queue the engine thread works through, and a writer thread that owns the output. +//! +//! The engine runs one command at a time, on the thread that called [`crate::Server::serve`]. The +//! reader never touches the engine. While a call runs it can still +//! +//! - act on [`notifications/cancelled`](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation): +//! a queued request is dropped, a running one has its [`CancelToken`] raised, and neither gets +//! a reply; +//! - answer the requests that need no engine (`ping`, the `…/list` methods, `prompts/get`), and +//! - refuse, with a "busy" error that names the running call, a request that would have to wait +//! behind a call that has already run for [`Server::with_busy_after`](crate::Server::with_busy_after). +//! +//! Requests that arrive sooner than that queue up and run in order, so a client that pipelines a +//! few quick calls sees them answered in order, as before. The queue is bounded: past +//! [`MAX_QUEUED`] lines the reader waits for the engine rather than buffering without bound. + +use std::collections::VecDeque; +use std::io::BufRead; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::SyncSender; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cancel::CancelToken; + +use crate::server::{INVALID_REQUEST, MAX_LINE_BYTES, PARSE_ERROR, SERVER_BUSY, error, error_with, read_line_capped}; + +/// Most lines waiting for the engine. Past this the reader waits (it still reads nothing else). +pub(crate) const MAX_QUEUED: usize = 64; + +/// A line the engine thread has still to run. +pub(crate) enum Job { + /// A reply that is already known (a parse error, an overlong line), kept in order with the rest. + Canned(String), + /// A parsed message: a request, a notification or a batch. + Message(Message), +} + +pub(crate) struct Message { + pub(crate) value: Value, + /// The request id as JSON text, for a request; what `notifications/cancelled` names. + pub(crate) key: Option, + pub(crate) method: String, + /// Raised by `notifications/cancelled`, or when the transport closes under the call. + pub(crate) token: CancelToken, + /// What the call is, for the "busy" answer: `tools/call command_run document.export`. + pub(crate) label: String, +} + +/// The call the engine thread is running. +struct Running { + key: Option, + method: String, + token: CancelToken, + label: String, + since: Instant, +} + +#[derive(Default)] +struct State { + queue: VecDeque, + running: Option, + /// The transport has no more lines. + eof: bool, + /// The output failed: nobody reads replies any more. + closed: bool, +} + +/// What the reader and the engine thread share. +pub(crate) struct Shared { + state: Mutex, + wake: Condvar, + /// Whether the client speaks the `2026-07-28` revision, for results answered by the reader. + pub(crate) modern: Arc, + busy_after: Duration, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + // A thread that panicked while holding the lock left a queue that is still well formed. + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Shared { + pub(crate) fn new(modern: Arc, busy_after: Duration) -> Self { + Self { state: Mutex::new(State::default()), wake: Condvar::new(), modern, busy_after } + } + + /// The next job, or `None` once the transport is done and the queue empty (or the output + /// failed). The job counts as running until [`Shared::finished`]. + pub(crate) fn next(&self) -> Option { + let mut st = lock(&self.state); + loop { + if st.closed { + return None; + } + if let Some(job) = st.queue.pop_front() { + if let Job::Message(m) = &job { + st.running = Some(Running { + key: m.key.clone(), + method: m.method.clone(), + token: m.token.clone(), + label: m.label.clone(), + since: Instant::now(), + }); + } + // The reader may be waiting for room. + self.wake.notify_all(); + return Some(job); + } + if st.eof { + return None; + } + st = self.wake.wait(st).unwrap_or_else(PoisonError::into_inner); + } + } + + pub(crate) fn finished(&self) { + lock(&self.state).running = None; + self.wake.notify_all(); + } + + /// The output failed: stop the running call and everything queued behind it. + pub(crate) fn close(&self) { + let mut st = lock(&self.state); + st.closed = true; + if let Some(r) = &st.running { + r.token.cancel(); + } + st.queue.clear(); + self.wake.notify_all(); + } + + fn end_of_input(&self) { + lock(&self.state).eof = true; + self.wake.notify_all(); + } + + fn is_closed(&self) -> bool { + lock(&self.state).closed + } + + /// The running call, once it has run for `busy_after` (so a quick call never makes a + /// pipelined neighbour fail). + fn long_running(&self, st: &State) -> Option { + let r = st.running.as_ref()?; + let secs = r.since.elapsed(); + (secs >= self.busy_after).then(|| json!({"running": r.label, "requestId": r.key, "seconds": secs.as_secs()})) + } +} + +/// The id of a request message: `Some` when `msg` is a JSON-RPC 2.0 request with a usable id. +fn request_id(msg: &Value) -> Option<&Value> { + let o = msg.as_object()?; + if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") || !o.get("method")?.is_string() { + return None; + } + o.get("id").filter(|id| id.is_string() || id.is_number()) +} + +/// A short name for the call, as the "busy" answer shows it. +fn label_of(method: &str, params: &Value) -> String { + if method != "tools/call" { + return method.to_string(); + } + let name = params.get("name").and_then(Value::as_str).unwrap_or("?"); + let args = params.get("arguments"); + let which = ["id", "command", "path"].iter().find_map(|k| args?.get(k)?.as_str()); + match which { + Some(w) => format!("{method} {name} {w}"), + None => format!("{method} {name}"), + } +} + +/// Read lines from `reader` until the end, acting on each as described in the module docs. +pub(crate) fn read_loop(shared: &Shared, out: &SyncSender, mut reader: impl BufRead) -> std::io::Result<()> { + let mut buf = Vec::new(); + let result = loop { + match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) { + Ok(true) => {} + Ok(false) => break Ok(()), + Err(e) => break Err(e), + } + if shared.is_closed() { + break Ok(()); + } + if buf.len() > MAX_LINE_BYTES { + // Longer than anything a client can mean: answer the framing violation, skip the + // rest of the line in bounded chunks, and keep serving what follows. + let reply = error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string(); + submit_canned(shared, out, reply); + let ended = loop { + match read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES) { + Ok(true) if buf.len() > MAX_LINE_BYTES => {} + Ok(true) => break Ok(false), + Ok(false) => break Ok(true), + Err(e) => break Err(e), + } + }; + match ended { + Ok(false) => continue, + Ok(true) => break Ok(()), + Err(e) => break Err(e), + } + } + let line = match std::str::from_utf8(&buf) { + Ok(l) => l, + Err(e) => break Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)), + }; + intake(shared, out, line); + }; + shared.end_of_input(); + result +} + +/// A reply that is already known: in order behind queued work, or at once when the engine is +/// tied up for long (then nobody can expect order). +fn submit_canned(shared: &Shared, out: &SyncSender, reply: String) { + let mut st = lock(&shared.state); + if shared.long_running(&st).is_some() { + drop(st); + let _ = out.send(reply); + return; + } + st.queue.push_back(Job::Canned(reply)); + shared.wake.notify_all(); +} + +fn intake(shared: &Shared, out: &SyncSender, line: &str) { + let line = line.trim(); + if line.is_empty() { + return; + } + let value = match serde_json::from_str::(line) { + Ok(v) => v, + Err(e) => return submit_canned(shared, out, error(Value::Null, PARSE_ERROR, format!("parse error: {e}")).to_string()), + }; + let method = value.get("method").and_then(Value::as_str).unwrap_or("").to_string(); + let enveloped = value.get("jsonrpc").and_then(Value::as_str) == Some("2.0"); + if enveloped && method == "notifications/cancelled" && value.get("id").is_none() { + return cancel(shared, value.get("params").unwrap_or(&Value::Null)); + } + let params = value.get("params").cloned().unwrap_or(Value::Null); + let id = request_id(&value).cloned(); + let is_batch = value.is_array(); + let token = CancelToken::new(); + let msg = Message { key: id.as_ref().map(Value::to_string), label: label_of(&method, ¶ms), method, token, value }; + let mut st = lock(&shared.state); + loop { + if st.closed { + return; + } + let busy = shared.long_running(&st); + if let Some(busy) = busy { + if let Some(id) = &id { + // Engine-free requests are answered here; others are told to retry. + let reply = match crate::server::stateless_reply(&msg.value, shared.modern.load(Ordering::Relaxed)) { + Some(r) => r, + None => error_with(id.clone(), SERVER_BUSY, busy_message(&busy), json!({"busy": busy})), + }; + drop(st); + let _ = out.send(reply.to_string()); + return; + } + if is_batch { + drop(st); + let reply = error_with(Value::Null, SERVER_BUSY, busy_message(&busy), json!({"busy": busy})); + let _ = out.send(reply.to_string()); + return; + } + } + if st.queue.len() < MAX_QUEUED { + break; + } + // Wake now and then: the call that fills the queue may be the one that turns it away. + st = shared.wake.wait_timeout(st, Duration::from_millis(50)).unwrap_or_else(PoisonError::into_inner).0; + } + st.queue.push_back(Job::Message(msg)); + shared.wake.notify_all(); +} + +fn busy_message(busy: &Value) -> String { + format!( + "the engine is busy: `{}` has been running for {} s and this request was not run. Retry when it has finished, or cancel it with notifications/cancelled (requestId {})", + busy["running"].as_str().unwrap_or("a call"), + busy["seconds"].as_u64().unwrap_or(0), + busy["requestId"], + ) +} + +/// `notifications/cancelled {requestId}`: stop that request if it is still queued or running. +/// An unknown or finished id is ignored, as is a request that can't be cancelled (`initialize`). +fn cancel(shared: &Shared, params: &Value) { + let Some(id) = params.get("requestId").filter(|id| id.is_string() || id.is_number()) else { return }; + let key = id.to_string(); + let mut st = lock(&shared.state); + if let Some(r) = st.running.as_ref().filter(|r| r.key.as_deref() == Some(&key) && r.method != "initialize") { + log::debug!("cancelling running request {key}: {}", r.label); + r.token.cancel(); + return; + } + let at = st.queue.iter().position(|j| matches!(j, Job::Message(m) if m.key.as_deref() == Some(&key) && m.method != "initialize")); + if let Some(at) = at { + log::debug!("dropping queued request {key}"); + st.queue.remove(at); + shared.wake.notify_all(); + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 73a37272d..1c7b7020c 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -16,6 +16,7 @@ mod backend; mod headless; +mod intake; pub mod logging; mod prompts; mod resources; @@ -37,6 +38,8 @@ mod tests; #[cfg(test)] mod tests_batchdraw; #[cfg(test)] +mod tests_concurrency; +#[cfg(test)] mod tests_conventions; #[cfg(test)] mod tests_distortkeys; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index ba5b23c13..8fef0ee46 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -1,10 +1,18 @@ //! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods. +use std::cell::Cell; use std::io::{BufRead, Write}; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{SyncSender, sync_channel}; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; +use vectorcraft_engine::cancel; use crate::backend::Backend; +use crate::intake::{Job, Message, Shared, read_loop}; use crate::prompts; use crate::resources; use crate::tools::{call_tool, tool_definitions}; @@ -14,12 +22,18 @@ pub const PROTOCOL_VERSION: &str = "2025-06-18"; const MODERN_VERSION: &str = "2026-07-28"; const SUPPORTED_VERSIONS: &[&str] = &[MODERN_VERSION, PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]; -const PARSE_ERROR: i64 = -32700; -const INVALID_REQUEST: i64 = -32600; +pub(crate) const PARSE_ERROR: i64 = -32700; +pub(crate) const INVALID_REQUEST: i64 = -32600; const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; +/// The engine is running a long call and this request was not run (implementation-defined range). +pub(crate) const SERVER_BUSY: i64 = -32003; + +/// How long a call runs before a request that would have to wait behind it is refused as busy +/// instead ([`Server::with_busy_after`]). +const DEFAULT_BUSY_AFTER: Duration = Duration::from_secs(2); /// Longest accepted request line (bytes, without the newline). Requests are small JSON objects; /// anything longer is answered with an error and skipped, so one huge line can't make the server @@ -29,6 +43,40 @@ pub(crate) const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; /// older clients can make the server parse and answer at once (#1727). pub(crate) const MAX_BATCH_MESSAGES: usize = 64; +/// Lines waiting to be written. A full queue holds up the thread that wants to write (the +/// engine thread, the reader) rather than growing; progress is dropped instead (it is advisory). +const OUTBOX: usize = 256; +/// Fewest time between two `notifications/progress` of one request, the last one excepted. +const PROGRESS_EVERY: Duration = Duration::from_millis(200); + +/// The sink that turns the engine's progress into `notifications/progress` for the request +/// `msg`, when the client asked for it with `_meta.progressToken`. +/// +fn progress_sink(out: SyncSender, msg: &Value) -> Option { + let token = msg.get("params")?.get("_meta")?.get("progressToken").filter(|t| t.is_string() || t.is_number())?.clone(); + let last = Cell::new(None::); + let at = Cell::new(None::); + Some(Rc::new(move |done, total, what| { + // `progress` must increase with each notification. + if last.get().is_some_and(|l| done <= l) { + return; + } + let finished = total > 0 && done >= total; + if !finished && at.get().is_some_and(|t| t.elapsed() < PROGRESS_EVERY) { + return; + } + last.set(Some(done)); + at.set(Some(Instant::now())); + let mut params = json!({"progressToken": token, "progress": done, "message": what}); + if total > 0 { + params["total"] = json!(total); + } + let line = json!({"jsonrpc": "2.0", "method": "notifications/progress", "params": params}).to_string(); + // Advisory: a client that doesn't read fast enough misses an update, not the reply. + let _ = out.try_send(line); + })) +} + const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \ points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \ Draw with draw_shape / draw_path, change colours with set_paint, look with render_preview and doc_inspect. \ @@ -42,20 +90,88 @@ vectorcraft://swatch/{name} read one thing at a time instead of the whole docume pub struct Server { backend: Box, initialized: bool, - modern: bool, + /// Whether the client speaks the `2026-07-28` revision. Shared with the reader thread, which + /// answers some requests itself while the engine is busy. + modern: Arc, /// Whether this server's client asked for log records (`logging/setLevel`). The queue is /// process-wide, so a server whose client didn't ask leaves it to the one that did. logging: bool, + busy_after: Duration, } -fn response(id: Value, result: Value) -> Value { +pub(crate) fn response(id: Value, result: Value) -> Value { json!({"jsonrpc": "2.0", "id": id, "result": result}) } -fn error(id: Value, code: i64, message: impl Into) -> Value { +pub(crate) fn error(id: Value, code: i64, message: impl Into) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) } +pub(crate) fn error_with(id: Value, code: i64, message: impl Into, data: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into(), "data": data}}) +} + +/// The requests that need neither the engine nor the server's state beyond the protocol revision, +/// so the reader can answer them while the engine is busy. +pub(crate) fn stateless_request(method: &str, params: &Value) -> Option> { + Some(match method { + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "resources/list" => Ok(resources::list()), + "resources/templates/list" => Ok(resources::templates()), + "prompts/list" => Ok(prompts::list()), + "prompts/get" => { + let name = params.get("name").and_then(Value::as_str); + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + match name { + Some(name) => prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e)), + None => Err((INVALID_PARAMS, "missing prompt `name`".to_string())), + } + } + _ => return None, + }) +} + +/// The reply to `msg` when it is a request [`stateless_request`] answers. +pub(crate) fn stateless_reply(msg: &Value, modern: bool) -> Option { + let o = msg.as_object()?; + let method = o.get("method")?.as_str()?; + let id = o.get("id").filter(|id| id.is_string() || id.is_number())?.clone(); + if o.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return None; + } + let params = o.get("params").cloned().unwrap_or(Value::Null); + // A panic here is a bug: leave the request to the engine thread, which reports it. + let r = vectorcraft_engine::guard::catch_panic(|| stateless_request(method, ¶ms)).ok()??; + Some(match r { + Ok(r) => response(id, complete_result(method, ¶ms, modern, r)), + Err((code, m)) => error(id, code, m), + }) +} + +/// The cache hints the `2026-07-28` revision adds to list and read results. `modern` is whether the +/// session negotiated that revision; a request can also name it in its `_meta`. +pub(crate) fn complete_result(method: &str, params: &Value, modern: bool, mut r: Value) -> Value { + let modern = params + .get("_meta") + .and_then(|m| m.get("io.modelcontextprotocol/protocolVersion")) + .and_then(Value::as_str) + .map_or(modern, |v| v == MODERN_VERSION); + if modern && let Some(result) = r.as_object_mut() { + let ttl = match method { + "tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000), + "resources/read" | "prompts/get" => Some(0), + _ => None, + }; + if let Some(ttl) = ttl { + result.insert("resultType".into(), json!("complete")); + result.insert("ttlMs".into(), json!(ttl)); + result.insert("cacheScope".into(), json!("private")); + } + } + r +} + /// Read one `\n`-terminated line into `buf` (newline stripped). `Ok(false)` at end of stream. /// `buf` holding more than `max` bytes means the line is overlong: no newline arrived within the /// bound, so the bytes so far are only its start. Only `max + 1` bytes are ever buffered for one @@ -101,7 +217,15 @@ fn write_reply(output: &mut impl Write, line: &str) -> std::io::Result<()> { impl Server { pub fn new(backend: Box) -> Self { - Self { backend, initialized: false, modern: false, logging: false } + Self { backend, initialized: false, modern: Arc::new(AtomicBool::new(false)), logging: false, busy_after: DEFAULT_BUSY_AFTER } + } + + /// How long a call runs before [`Server::serve`] turns away a request that would have to wait + /// behind it, with an error naming the call (default two seconds). Shorter waits queue, so a + /// client pipelining quick calls is answered in order. + pub fn with_busy_after(mut self, after: Duration) -> Self { + self.busy_after = after; + self } pub fn backend(&mut self) -> &mut dyn Backend { @@ -130,36 +254,76 @@ impl Server { /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is /// the protocol stream). - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - let mut reader = input; - let mut buf = Vec::new(); - loop { - if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? { - return Ok(()); - } - if buf.len() > MAX_LINE_BYTES { - // Longer than anything a client can mean: answer the framing violation, skip the - // rest of the line in bounded chunks, and keep serving what follows. - write_reply( - &mut output, - &error(Value::Null, INVALID_REQUEST, format!("request line longer than {MAX_LINE_BYTES} bytes")).to_string(), - )?; - loop { - if !read_line_capped(&mut reader, &mut buf, MAX_LINE_BYTES)? { - return Ok(()); - } - if buf.len() <= MAX_LINE_BYTES { - break; + /// + /// A reader thread takes lines off `input` while the calling thread runs them, so the server + /// hears a client during a long call: `notifications/cancelled` stops a queued or running + /// request (which then gets no reply), and `ping` and the other engine-free requests are + /// answered at once. A request that would have to wait behind a call that has run for + /// [`Server::with_busy_after`] is refused with a "busy" error. Calls still run one at a time, + /// in arrival order, and replies of quick calls keep that order. See the module docs of + /// `intake` and `docs/mcp.md`. + /// + /// Returns when `input` ends and everything read has been answered, or when `output` fails + /// (the running call is then cancelled and the queue dropped; the reader thread ends with its + /// next line, which a client that closed our output closes as well). + pub fn serve(&mut self, input: R, mut output: W) -> std::io::Result<()> { + let shared = Shared::new(self.modern.clone(), self.busy_after); + let shared = &shared; + let (tx, rx) = sync_channel::(OUTBOX); + let reader_tx = tx.clone(); + std::thread::scope(|scope| { + // The only thread that writes: replies, progress and log records stay whole lines. + let writer = scope.spawn(move || { + let mut failed = None; + for line in rx { + if failed.is_none() + && let Err(e) = write_reply(&mut output, &line) + { + failed = Some(e); + shared.close(); } } - continue; + failed + }); + let reader = scope.spawn(move || read_loop(shared, &reader_tx, input)); + self.work(shared, &tx); + drop(tx); + let read = reader.join().unwrap_or_else(|_| Err(std::io::Error::other("the MCP reader thread panicked"))); + let written = writer.join().unwrap_or_else(|_| Some(std::io::Error::other("the MCP writer thread panicked"))); + match written { + Some(e) => Err(e), + None => read, } - let line = std::str::from_utf8(&buf).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - let reply = self.handle_line(line); - let notes = self.take_notifications(); - for line in reply.iter().chain(notes.iter()) { - write_reply(&mut output, line)?; + }) + } + + /// Run the queued jobs, one at a time, until the reader is done and the queue empty. + fn work(&mut self, shared: &Shared, out: &SyncSender) { + while let Some(job) = shared.next() { + match job { + Job::Canned(line) => { + let _ = out.send(line); + } + Job::Message(m) => self.run(m, out), } + for note in self.take_notifications() { + let _ = out.send(note); + } + shared.finished(); + } + } + + fn run(&mut self, m: Message, out: &SyncSender) { + let Message { value, key, token, .. } = m; + let sink = key.is_some().then(|| progress_sink(out.clone(), &value)).flatten(); + let reply = cancel::scope(&token, sink, || self.handle_parsed(value)); + // A cancelled request gets no reply (MCP: the receiver should not answer it). A call that + // finished before it noticed is dropped the same way: the client has stopped waiting. + if key.is_some() && token.is_cancelled() { + return; + } + if let Some(reply) = reply { + let _ = out.send(reply.to_string()); } } @@ -170,7 +334,16 @@ impl Server { return None; } let reply = match serde_json::from_str::(line) { - Ok(Value::Array(batch)) => { + Ok(value) => self.handle_parsed(value), + Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// Handle one parsed line: a message, or a batch of them. + fn handle_parsed(&mut self, value: Value) -> Option { + match value { + Value::Array(batch) => { // Batches were removed in 2025-06-18; still answer older clients sensibly. if batch.is_empty() { Some(error(Value::Null, INVALID_REQUEST, "empty batch")) @@ -181,10 +354,8 @@ impl Server { (!replies.is_empty()).then_some(Value::Array(replies)) } } - Ok(msg) => self.handle(msg), - Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), - }; - reply.map(|r| r.to_string()) + msg => self.handle(msg), + } } /// Handle one JSON-RPC message; `None` for notifications and responses. @@ -216,26 +387,7 @@ impl Server { let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, ¶ms)) .unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)")))); Some(match r { - Ok(mut r) => { - let modern = params - .get("_meta") - .and_then(|m| m.get("io.modelcontextprotocol/protocolVersion")) - .and_then(Value::as_str) - .map_or(self.modern, |v| v == MODERN_VERSION); - if modern && let Some(result) = r.as_object_mut() { - let ttl = match method { - "tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000), - "resources/read" | "prompts/get" => Some(0), - _ => None, - }; - if let Some(ttl) = ttl { - result.insert("resultType".into(), json!("complete")); - result.insert("ttlMs".into(), json!(ttl)); - result.insert("cacheScope".into(), json!("private")); - } - } - response(id, r) - } + Ok(r) => response(id, complete_result(method, ¶ms, self.modern.load(Ordering::Relaxed), r)), Err((code, m)) => error(id, code, m), }) } @@ -249,11 +401,14 @@ impl Server { } fn request(&mut self, method: &str, params: &Value) -> Result { + if let Some(r) = stateless_request(method, params) { + return r; + } match method { "initialize" => { let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; - self.modern = version == MODERN_VERSION; + self.modern.store(version == MODERN_VERSION, Ordering::Relaxed); Ok(json!({ "protocolVersion": version, "capabilities": { @@ -267,8 +422,6 @@ impl Server { "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), })) } - "ping" => Ok(json!({})), - "tools/list" => Ok(json!({"tools": tool_definitions()})), "tools/call" => { let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; let args = params.get("arguments").cloned().unwrap_or(Value::Null); @@ -277,8 +430,6 @@ impl Server { } Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) } - "resources/list" => Ok(resources::list()), - "resources/templates/list" => Ok(resources::templates()), "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e { @@ -288,12 +439,6 @@ impl Server { })?; Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) } - "prompts/list" => Ok(prompts::list()), - "prompts/get" => { - let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?; - let args = params.get("arguments").cloned().unwrap_or(Value::Null); - prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e)) - } "completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)), "logging/setLevel" => { // `level` is required. A null level is our own way of turning logging back off, diff --git a/crates/mcp/src/tests_concurrency.rs b/crates/mcp/src/tests_concurrency.rs new file mode 100644 index 000000000..c2e23a77a --- /dev/null +++ b/crates/mcp/src/tests_concurrency.rs @@ -0,0 +1,368 @@ +//! Reading while a call runs: `ping`, cancellation and busy answers reach the client while a long +//! command holds the engine (#2165, #2171). Driven through [`Server::serve`] on a pipe, the way a +//! client on stdio drives it, with a backend that works until it is told to stop. + +use std::io::{BufReader, Write}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_engine::cancel; + +use crate::{Backend, Server}; + +/// A backend whose `engine.execute` runs until cancelled (or for `limit`), like a long export. +struct Slow { + limit: Duration, + /// Commands that ran to the end, and the ones that were cancelled. + log: Arc>>, + /// Told when a command starts. + started: Sender, +} + +impl Backend for Slow { + fn call(&mut self, method: &str, params: Value) -> Result { + if method != "engine.execute" { + return Ok(json!({"method": method})); + } + let id = params.get("command").and_then(Value::as_str).unwrap_or("?").to_string(); + self.started.send(id.clone()).ok(); + if id == "progress.demo" { + for page in 1..=3 { + // Enough apart to pass the sender's throttle. + std::thread::sleep(Duration::from_millis(230)); + cancel::progress(page, 3, &format!("page {page}")); + } + return Ok(json!({"ran": id})); + } + let limit = if id.starts_with("quick") { Duration::ZERO } else { self.limit }; + let start = Instant::now(); + while start.elapsed() < limit { + if let Err(e) = cancel::check() { + self.log.lock().unwrap().push(format!("cancelled {id}")); + return Err(e.to_string()); + } + std::thread::sleep(Duration::from_millis(5)); + } + self.log.lock().unwrap().push(format!("done {id}")); + Ok(json!({"ran": id})) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "slow".into() + } +} + +/// Collects the lines the server writes. +struct Lines(Sender, Vec); + +impl Write for Lines { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + for &b in buf { + if b == b'\n' { + self.0.send(String::from_utf8_lossy(&std::mem::take(&mut self.1)).into_owned()).ok(); + } else { + self.1.push(b); + } + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// A running server with a pipe in and a channel out. +struct Rig { + to_server: std::io::PipeWriter, + replies: Receiver, + started: Receiver, + log: Arc>>, + thread: Option>, +} + +impl Rig { + fn new(limit: Duration, busy_after: Duration) -> Self { + let (read, to_server) = std::io::pipe().unwrap(); + let (tx, replies) = mpsc::channel(); + let (started_tx, started) = mpsc::channel(); + let log = Arc::new(Mutex::new(vec![])); + let backend = Slow { limit, log: log.clone(), started: started_tx }; + let thread = std::thread::spawn(move || { + let mut server = Server::new(Box::new(backend)).with_busy_after(busy_after); + server.serve(BufReader::new(read), Lines(tx, vec![])).unwrap(); + }); + Self { to_server, replies, started, log, thread: Some(thread) } + } + + fn send(&mut self, msg: Value) { + writeln!(self.to_server, "{msg}").unwrap(); + } + + fn request(&mut self, id: u64, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + } + + fn run(&mut self, id: u64, command: &str) { + self.request(id, "tools/call", json!({"name": "command_run", "arguments": {"id": command}})); + } + + fn notify(&mut self, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "method": method, "params": params})); + } + + fn next(&self, within: Duration) -> Option { + match self.replies.recv_timeout(within) { + Ok(l) => serde_json::from_str(&l).ok(), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => None, + } + } + + /// The next reply that answers request `id`, skipping notifications. + fn reply(&self, id: u64, within: Duration) -> Option { + let end = Instant::now() + within; + while let Some(left) = end.checked_duration_since(Instant::now()) { + let v = self.next(left)?; + if v.get("id") == Some(&json!(id)) { + return Some(v); + } + } + None + } + + fn finish(mut self) { + let thread = self.thread.take().unwrap(); + drop(self.to_server); + thread.join().unwrap(); + } +} + +const LONG: Duration = Duration::from_secs(20); +const QUICK: Duration = Duration::from_secs(3); + +#[test] +fn ping_is_answered_while_a_command_runs() { + let mut rig = Rig::new(LONG, Duration::from_millis(50)); + rig.run(1, "document.export"); + assert_eq!(rig.started.recv_timeout(QUICK).unwrap(), "document.export"); + // Past `busy_after` the call counts as long-running. + std::thread::sleep(Duration::from_millis(120)); + rig.request(2, "ping", json!({})); + let pong = rig.reply(2, QUICK).expect("ping must not wait for the running command"); + assert_eq!(pong["result"], json!({})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + rig.finish(); +} + +#[test] +fn cancelling_stops_the_running_command_and_sends_no_reply() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + rig.notify("notifications/cancelled", json!({"requestId": 1, "reason": "user"})); + // The engine is free again: the next command runs and answers. + rig.run(2, "pdf.preset.list"); + let end = Instant::now() + QUICK; + loop { + if rig.log.lock().unwrap().iter().any(|l| l == "cancelled document.export") { + break; + } + assert!(Instant::now() < end, "the export kept running: {:?}", rig.log.lock().unwrap()); + std::thread::sleep(Duration::from_millis(10)); + } + rig.notify("notifications/cancelled", json!({"requestId": 2})); + // Request 1 was cancelled: nothing answers it (MCP: the receiver should not reply). + assert!(rig.reply(1, Duration::from_millis(300)).is_none()); + rig.finish(); +} + +#[test] +fn a_request_that_would_wait_behind_a_long_call_is_refused_as_busy() { + let mut rig = Rig::new(LONG, Duration::from_millis(50)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + std::thread::sleep(Duration::from_millis(120)); + rig.run(2, "pdf.preset.list"); + let busy = rig.reply(2, QUICK).expect("a busy answer, not a wait"); + assert_eq!(busy["error"]["code"], -32003, "{busy}"); + assert!(busy["error"]["message"].as_str().unwrap().contains("document.export"), "{busy}"); + assert_eq!(busy["error"]["data"]["busy"]["requestId"], "1", "{busy}"); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + // The refused request never ran, and the engine is free once the export stops. + rig.run(3, "pdf.preset.list"); + rig.notify("notifications/cancelled", json!({"requestId": 3})); + rig.finish(); +} + +#[test] +fn quick_calls_pipelined_together_queue_and_answer_in_order() { + let mut rig = Rig::new(Duration::from_millis(30), Duration::from_secs(60)); + for id in 1..=4 { + rig.run(id, &format!("cmd{id}")); + } + for id in 1..=4 { + let r = rig.reply(id, QUICK).unwrap_or_else(|| panic!("no reply to {id}")); + assert!(r.get("error").is_none(), "{r}"); + } + assert_eq!(*rig.log.lock().unwrap(), ["done cmd1", "done cmd2", "done cmd3", "done cmd4"]); + rig.finish(); +} + +#[test] +fn a_queued_request_that_is_cancelled_never_runs() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.run(1, "document.export"); + rig.started.recv_timeout(QUICK).unwrap(); + rig.run(2, "never.runs"); + rig.run(3, "quick.last"); + // Request 2 may not have been read yet when the cancel is: it is the order of lines that counts. + rig.notify("notifications/cancelled", json!({"requestId": 2})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + let r = rig.reply(3, QUICK).expect("the engine goes on to the next request"); + assert!(r.get("error").is_none(), "{r}"); + rig.notify("notifications/cancelled", json!({"requestId": 3})); + let log = rig.log.lock().unwrap().clone(); + assert!(log.iter().all(|l| !l.contains("never.runs")), "{log:?}"); + assert!(log.iter().any(|l| l == "cancelled document.export"), "{log:?}"); + drop(log); + rig.finish(); +} + +#[test] +fn cancelling_an_unknown_or_finished_request_changes_nothing() { + let mut rig = Rig::new(Duration::from_millis(10), Duration::from_secs(60)); + rig.run(1, "quick"); + assert!(rig.reply(1, QUICK).unwrap().get("result").is_some()); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + rig.notify("notifications/cancelled", json!({"requestId": 99})); + rig.notify("notifications/cancelled", json!({"requestId": [1], "reason": "junk"})); + rig.notify("notifications/cancelled", json!("junk")); + rig.run(2, "quick"); + assert!(rig.reply(2, QUICK).unwrap().get("result").is_some()); + rig.finish(); +} + +#[test] +fn initialize_cannot_be_cancelled() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.request(1, "initialize", json!({})); + rig.notify("notifications/cancelled", json!({"requestId": 1})); + assert!(rig.reply(1, QUICK).unwrap().get("result").is_some()); + rig.finish(); +} + +#[test] +fn progress_reaches_a_client_that_gave_a_token_before_the_reply() { + let mut rig = Rig::new(LONG, Duration::from_secs(60)); + rig.request(1, "tools/call", json!({"name": "command_run", "arguments": {"id": "progress.demo"}, "_meta": {"progressToken": "tok"}})); + let mut seen = vec![]; + let reply = loop { + let v = rig.next(QUICK).expect("a reply"); + if v["method"] == "notifications/progress" { + seen.push(v["params"].clone()); + } else { + break v; + } + }; + assert!(reply.get("result").is_some(), "{reply}"); + assert_eq!(seen.len(), 3, "{seen:?}"); + let values: Vec = seen.iter().map(|p| p["progress"].as_u64().unwrap()).collect(); + assert_eq!(values, [1, 2, 3]); + assert!(seen.iter().all(|p| p["progressToken"] == "tok" && p["total"] == 3), "{seen:?}"); + assert_eq!(seen[1]["message"], "page 2"); + // Without a token nothing is sent. + rig.run(2, "progress.demo"); + let next = rig.next(QUICK).unwrap(); + assert_eq!(next["id"], 2, "{next}"); + rig.finish(); +} + +/// The real engine behind the server: waits (like a slow render would) until the call is +/// cancelled, then lets the export run into the cancelled token. +struct Gated { + inner: crate::Headless, + started: Sender<()>, +} + +impl Backend for Gated { + fn call(&mut self, method: &str, params: Value) -> Result { + if method == "engine.execute" && params["command"] == "document.export" { + self.started.send(()).ok(); + let end = Instant::now() + LONG; + while !cancel::is_cancelled() && Instant::now() < end { + std::thread::sleep(Duration::from_millis(5)); + } + } + self.inner.call(method, params) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "gated".into() + } +} + +#[test] +fn a_cancelled_export_through_the_real_engine_writes_nothing_and_leaves_the_session_usable() { + let path = vectorcraft_testkit::temp_dir("mcp-cancel").join("out.pdf"); + std::fs::remove_file(&path).ok(); + let (read, mut to_server) = std::io::pipe().unwrap(); + let (tx, replies) = mpsc::channel(); + let (started_tx, started) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let backend = Gated { inner: crate::Headless::with_document(), started: started_tx }; + Server::new(Box::new(backend)).serve(BufReader::new(read), Lines(tx, vec![])).unwrap(); + }); + let send = |w: &mut std::io::PipeWriter, v: Value| writeln!(w, "{v}").unwrap(); + let params = json!({"name": "command_run", "arguments": {"id": "document.export", "params": {"path": path.to_str().unwrap()}}}); + send(&mut to_server, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})); + started.recv_timeout(QUICK).unwrap(); + send(&mut to_server, json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 1}})); + // The next request is answered: the cancelled export neither blocks nor breaks the engine. + send(&mut to_server, json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}})); + let reply: Value = serde_json::from_str(&replies.recv_timeout(QUICK).unwrap()).unwrap(); + assert_eq!(reply["id"], 2, "request 1 is not answered: {reply}"); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + assert!(!path.exists(), "a cancelled export writes no file"); + drop(to_server); + thread.join().unwrap(); +} + +#[test] +fn a_remote_call_stops_waiting_for_the_app_when_cancelled() { + use std::io::BufRead; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + // An app that takes the request and never answers. + let app = std::thread::spawn(move || { + let (conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + // Hold the connection open until the test is done with it. + std::thread::sleep(Duration::from_secs(3)); + drop(conn); + }); + let mut remote = crate::Remote::connect(&addr).unwrap(); + let token = cancel::CancelToken::new(); + let raise = token.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + raise.cancel(); + }); + let start = Instant::now(); + let r = cancel::scope(&token, None, || remote.call("engine.execute", json!({"command": "document.export"}))); + let why = r.unwrap_err(); + assert!(why.contains("cancelled"), "{why}"); + assert!(start.elapsed() < Duration::from_secs(2), "returned when cancelled, not when the app answered"); + app.join().unwrap(); +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 7aad0fb9d..9605a0ca7 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -617,3 +617,64 @@ fn tools_never_leave_interactions_open() { } assert!(failures.is_empty(), "{}", failures.join("\n")); } + +proptest! { + #![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })] + + /// Requests mixed with `notifications/cancelled` (for ids that are queued, running, finished, + /// unknown or junk) read from one stream: the server ends, every request nobody cancelled is + /// answered exactly once, a cancelled one at most once, nothing is answered that wasn't asked, + /// replies keep the order of the requests, and the document stays valid. + #[test] + fn cancellation_never_loses_or_duplicates_an_answer( + steps in prop::collection::vec((0u8..6, 0u64..10), 1..40), + ) { + let mut input = String::new(); + let mut asked: Vec = vec![]; + let mut cancelled: Vec = vec![]; + for (i, &(kind, n)) in steps.iter().enumerate() { + let id = i as u64 + 1; + let line = match kind { + 0 => json!({"jsonrpc": "2.0", "id": id, "method": "ping"}), + 1 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/list"}), + 2 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 1, "y": 2, "width": 30, "height": 20}}}), + 3 => json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": "command_run", "arguments": {"id": "select.all"}}, "_meta": {"progressToken": id}}), + // A cancel for an id that may be queued, finished or never asked. + 4 => { + let target = n.min(id.saturating_sub(1)).max(1); + cancelled.push(target); + input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": target}}))); + continue; + } + // Junk cancels never take anything with them. + _ => { + let junk = [json!(null), json!([1]), json!({"a": 1}), json!("nope"), json!(1.5), json!(-1)]; + input.push_str(&format!("{}\n", json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": junk[n as usize % junk.len()]}}))); + continue; + } + }; + asked.push(id); + input.push_str(&format!("{line}\n")); + } + let mut s = server(); + let mut out = Vec::new(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let ids: Vec = String::from_utf8(out) + .unwrap() + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .filter(|v| v.get("method").is_none()) + .map(|v| v["id"].as_u64().unwrap()) + .collect(); + prop_assert!(ids.windows(2).all(|w| w[0] < w[1]), "answered once each, in request order: {:?}", ids); + for id in &asked { + if !cancelled.contains(id) { + prop_assert!(ids.contains(id), "request {} was never answered: {:?}", id, ids); + } + } + for id in &ids { + prop_assert!(asked.contains(id), "answer to a request nobody made: {}", id); + } + check_document(&document(&mut s)).unwrap(); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1da3b3710..7d729846e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -44,8 +44,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result { let set = &opts.settings; set.check()?; + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; // Live geometry effects export as their result; raster effects are reported below. let baked = vectorcraft_effects::bake_document(doc); + // Baking stops early when cancelled, leaving a half-baked copy that must not be written. + vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let doc = baked.as_ref().unwrap_or(doc); let pages = pages(doc, opts)?; @@ -77,7 +80,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result Result **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (G6: MCP cancellation, progress notifications and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; correction: quick Image Trace presets leave the panel as it was, the Image Trace button opens it; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); G1: Smart Guides line extension, #2120; G16: Brazilian Portuguese catalog complete under the tests, #1914; Localization 71% → 74%, hours 55–90 → 50–85; G6: image variables landed, estimate 8–14 → 6–12 h; Warp Options controls verified by focused checks and original-image review; estimates unchanged; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -283,6 +283,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, graph variable kinds. Actions, Variables (text, visibility and image), dataset CSV import/export and full agent control over kinds, dataset import/export. Actions, Variables (text, visibility and image) and full agent control over +- **Done (2026-10-16):** the MCP server hears the client during a long call: `notifications/cancelled` stops a queued or running export (PDF pages, flattening, baking, drawing, artboards) without writing anything or changing the document, `ping` and the list methods answer at once, any other request that would wait behind a call running for two seconds or more is refused as busy (`-32003`, naming the call), and `_meta.progressToken` gets page progress (#2165, #2171, #2172). Reading the document during an export is not served (refused as busy); estimate unchanged. - **Exposed in MCP:** `add_text {vertical: true}` uses the existing vertical point, area and path type commands. Horizontal writing remains the default; the broader automation gap and estimate are unchanged. - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image and graph variable kinds, dataset import/export. Actions, Variables (text and visibility) and full agent control over @@ -335,6 +336,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G6: MCP cancellation, progress notifications and a busy answer during long exports (#2165, #2171, #2172); no entry closed, estimates unchanged | | 2026-10-16 | minor | G11: PDF export of a CMYK document reports its RGB colours and non-CMYK images, with a count, in the export warnings (#2162); no entry closed, estimates unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5d8f0c204..e0f2fb9e5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -225,36 +225,57 @@ crate that installed one would override whatever logger an embedder had already `vectorcraft-mcp` directly means calling `vectorcraft_mcp::logging::install()` yourself if you want log records to reach your client. -### Synchronous calls +### Long calls: cancellation, progress and a busy engine -Exports complete synchronously. A `_meta.progressToken` is harmlessly ignored, as are -`notifications/cancelled` (including unknown request ids). There are no background export jobs -or progress notifications; cancellation cannot interrupt a running call. +A call runs to the end before its reply, and the engine runs one call at a time, in the order the +lines arrived. A thread of its own reads the stream meanwhile, so the server still hears the client +while an export runs (#2165, #2171, #2172): + +- **Cancellation.** `notifications/cancelled {requestId}` + ([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation)) + takes a request out of the queue, or stops the one that is running, and **that request gets no + reply**. A running export, flatten, bake or render notices at its next checkpoint (between pages + and artboards, between flattened groups, while drawing objects), returns, and writes nothing: the + document is exactly as it was and the engine is free for the next call. An unknown, finished or + malformed `requestId` is ignored, and so is a cancel of `initialize`. A call without checkpoints + (every ordinary edit is over in milliseconds) runs to its end, but its reply is dropped all the + same. Against a running app (`--connect`) the server stops waiting and drops the connection, but + the app cannot be told to stop what it started. +- **Progress.** A request with `_meta.progressToken` gets `notifications/progress` + ([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress)) before + its reply: the PDF writer reports "Writing page 2 of 5" with `progress` 2 and `total` 5, raster + export reports each artboard, and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always), + and none follows the reply. A client that does not read fast enough misses updates, never the reply. +- **A busy engine.** `ping`, `tools/list`, `resources/list`, `resources/templates/list`, + `prompts/list` and `prompts/get` need no engine and are answered at once, whatever is running. + Anything else that arrives while a call has already run for two seconds is not queued behind it: + it is answered with error `-32003` (`data.busy` names the running call, its request id and how + long it has run) and was **not run**, so the client can retry later or cancel the running call. + Requests that arrive sooner than that queue and are answered in order, as before, so a client + that sends a few quick calls together sees no difference. At most 64 lines wait; past that the + server stops reading until the engine catches up. + +`vectorcraft_mcp::Server::serve` takes the input and the output on separate threads (`Send`), and +`Server::with_busy_after` changes the two seconds. Calling `handle_line` directly is as before: +one line in, one reply out, no threads. ### Not implemented -Everything below needs the same thing first: the server reads one line at a time and answers it with -one reply, so **while a `tools/call` is running it neither reads an incoming line nor writes an -outgoing one.** Progress and cancellation are therefore both meaningless as they stand, and a -subscription could only ever be checked between calls — which is not what a client asking to be told -about a change expects. - - **`resources/subscribe` / `notifications/resources/updated`.** It would also have to be honest about *whose* edits it sees: comparing the undo history's length misses edits made in the app window, which is the main reason to run against a live document. `document.inspect`'s `revision` is the right signal — it is monotonic and already bumped when a document is replaced — but noticing an edit still means someone asking. -- **`notifications/progress`.** It would have to be written *before* the reply it belongs to, since a - progress token stops meaning anything once the request has completed. -- **`notifications/cancelled`.** It would have to be read *during* the call it cancels. -- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests, blocked by - the same one-line-one-reply loop. Supporting them means being able to suspend a tool call - mid-flight, which is what the `2026-07-28` revision's input-requests mechanism exists for. +- **Reading the document during a long call.** Requests that need the engine (`doc_inspect`, + `resources/read`, `command_list`) are refused as busy while an export runs, not answered from a + snapshot. +- **`sampling/createMessage` and `elicitation/create`.** Both are server→client requests. Supporting + them means being able to suspend a tool call mid-flight, which is what the `2026-07-28` revision's + input-requests mechanism exists for. - **Streamable HTTP / SSE.** stdio only. `completion/complete` covers the "what could I pass here" half of the same problem, which is why it -is here and subscriptions are not. The fix for the rest is a reader thread (or an event loop) around -the transport — a design change rather than a feature, so it is not in this crate yet. +is here and subscriptions are not. ## Tools diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b35db0fb8..f8f68e48a 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-10-16 · **Change:** minor (EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (MCP cancellation, progress and busy answers during long exports, #2165, #2171, #2172; EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged; native family-name fallback from English-only typographic names, #2208; scores unchanged; batch exporting, #1935; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); G5: Symbol Options Export Type (Graphic / Movie Clip); Localization: Brazilian Portuguese complete under the tests, #1914, 71% → 74%; G6 image variables: Automation ~60% → ~62% / detail ~65% → ~68%, hours lowered; Warp Options controls verified by focused checks and original-image review; estimates unchanged; G11: Asset Export Art Optimized seam (#983 / #1037) and supersampled thumbs; Export for Screens 85% → 90%; existing-guide Escape cancellation validated 2026-10-14; scores and hours unchanged; open-path anchor Delete keeps surviving runs separate; estimates unchanged; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; Artboard tab-switch follow-up historically validated on `a0671777`; scores and hours unchanged; Artboard clean active-tab close/Delete follow-up validated 2026-10-15; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; File interop: legacy `.ai` of version 7 saved with type as text, hidden objects kept and an artboard picker, #868, scores unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -418,7 +418,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JSON-lines control channel with real egui pointer and keyboard injection. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. - - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). + - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785). - Actions panel that records and plays back commands. @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Agent automation: MCP cancellation, progress notifications and busy answers during long exports (#2165, #2171, #2172); scores and hours unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Type: localized legacy family names remain available when typographic names are English-only (#2208); loaded and catalog paths tested. Scores and estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | From a8e45f0e2d1f30140b1bf3d5296de05e1023a38a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:45:24 +0200 Subject: [PATCH 061/184] Persistence: saved PDF presets and loaded ICC profiles survive a restart (#2168, #2174) PDF presets are kept one file each in a PDF Presets folder, ICC profiles as copies in a Color Profiles folder, next to the preferences; the desktop app and vectorcraft-cli use them. Adds color.removeProfile. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-cli/src/main.rs | 15 +- apps/vectorcraft-cli/tests/cli.rs | 94 +++++++++++++ apps/vectorcraft/src/main.rs | 8 +- apps/vectorcraft/src/prefs_dir.rs | 25 ++++ crates/color/src/cms/mod.rs | 22 ++- crates/engine/src/cmd/colormgmt.rs | 40 +++++- crates/engine/src/cmd/pdfcmds.rs | 22 ++- crates/engine/src/lib.rs | 11 ++ crates/engine/src/store.rs | 211 ++++++++++++++++++++++++++++ crates/engine/src/tests_store.rs | 128 +++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/development.md | 3 +- docs/mcp.md | 25 +++- 31 files changed, 605 insertions(+), 18 deletions(-) create mode 100644 crates/engine/src/store.rs create mode 100644 crates/engine/src/tests_store.rs diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index b843af67f..8d09dcb68 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -159,6 +159,15 @@ fn main() -> ExitCode { } } +/// A headless session that remembers what the user keeps between runs: the PDF presets saved and +/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop +/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there. +fn with_stores(mut h: Headless) -> Headless { + prefs_dir::attach_profile_store(&mut h.session); + prefs_dir::attach_pdf_preset_store(&mut h.session); + h +} + fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut headless = false; @@ -198,14 +207,14 @@ fn mcp(args: &[String]) -> Result<(), String> { eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); Box::new(Headless::with_document().with_automation_roots(Some(roots))) } else if headless { - Box::new(Headless::with_document()) + Box::new(with_stores(Headless::with_document())) } else if let Some(addr) = connect { // Explicit address: fail loudly if the app isn't there. Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) } else { match Remote::connect(DEFAULT_ADDR) { Ok(r) => Box::new(r), - Err(_) => Box::new(Headless::with_document()), + Err(_) => Box::new(with_stores(Headless::with_document())), } }; eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe()); @@ -327,7 +336,7 @@ fn run(args: &[String]) -> Result<(), String> { } } - let mut h = Headless::new(); + let mut h = with_stores(Headless::new()); let mut out = std::io::stdout().lock(); // A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later // steps (an export…) still run, unprinted. diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 133c51df1..32c5e7f09 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -367,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() { let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap(); assert!(!out.status.success()); } + +/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the +/// developer's real profile. +fn config_dir(name: &str) -> std::path::PathBuf { + let dir = tmp(&format!("config-{name}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`; +/// the result of each step. +fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec { + let mut cmd = Command::new(BIN); + cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS"); + for (id, params) in steps { + cmd.args(["--cmd", id, "--params", ¶ms.to_string()]); + } + let out = cmd.output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::(l).unwrap()["result"].clone()).collect() +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +fn icc_file_named(path: &std::path::Path, name: &str) { + let builtin = "Display P3"; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile"); + bytes[at..at + to.len()].copy_from_slice(&to); + std::fs::write(path, bytes).unwrap(); +} + +fn profile_names(settings: &Value) -> Vec<&str> { + settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect() +} + +/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in +/// the next run, and gone again once deleted. +#[test] +fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() { + let config = config_dir("restart"); + let icc = tmp("studio-p3.icc"); + icc_file_named(&icc, "StudioRGB3"); + let r = run_in( + &config, + &[ + ("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})), + ("color.loadProfile", json!({"path": icc.to_str().unwrap()})), + ], + ); + assert_eq!(r[1]["name"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + let presets: Vec<&str> = + r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(presets, ["Press"]); + assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer"); + assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1])); + // The profile works as a working space, and the file it came from can go. + std::fs::remove_file(&icc).unwrap(); + let steps = [ + ("edit.colorSettings", json!({"rgb": "StudioRGB3"})), + ("pdf.preset.delete", json!({"name": "Press"})), + ("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})), + ("color.removeProfile", json!({"name": "StudioRGB3"})), + ]; + let r = run_in(&config, &steps); + assert_eq!(r[0]["rgb"], "StudioRGB3"); + assert_eq!(r[3]["removed"], "StudioRGB3"); + + let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); + assert!(!profile_names(&r[1]).contains(&"StudioRGB3")); +} + +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and +/// leave nothing in the settings folder. +#[test] +fn runs_without_preferences_remember_nothing() { + let config = config_dir("noprefs"); + let out = Command::new(BIN) + .args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#]) + .env("VECTORCRAFT_CONFIG_DIR", &config) + .env("VECTORCRAFT_NO_PREFS", "1") + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(!config.exists(), "nothing written"); + let r = run_in(&config, &[("pdf.preset.list", json!({}))]); + assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true)); +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 23aaea179..6046bcaa6 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -854,8 +854,14 @@ fn main() -> std::process::ExitCode { "VectorCraft", options, Box::new(move |cc| { - let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone())); + let mut session = Session::new(); + // The profiles loaded before come back before the preferences name any of them. + prefs_dir::attach_profile_store(&mut session); + let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone())); load_prefs(&mut app, saved); + // The saved PDF presets live in their own folder (also read by vectorcraft-cli), + // which takes over from the copy the preferences file used to hold. + prefs_dir::attach_pdf_preset_store(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). if let Some(w) = cc.winit_window() { app.ui.window = Some(window::restore(w, saved_window)); diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 4dd258afa..00298342d 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -24,6 +24,31 @@ pub fn install_fonts() { } } +/// The folder `name` next to the settings, for what the user keeps: none in a run without +/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing. +fn store_dir(name: &str) -> Option { + if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { + return None; + } + Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name)) +} + +/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and +/// keep the ones loaded from now on there. Before anything names a profile. +pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("Color Profiles") { + session.attach_profile_store(dir); + } +} + +/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from +/// now on there. After the preferences were restored: the folder is the one place the presets live. +pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) { + if let Some(dir) = store_dir("PDF Presets") { + session.attach_pdf_preset_store(dir); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 9eeafdb2c..f2a705857 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result bool { + let mut u = USER.write().unwrap_or_else(|e| e.into_inner()); + let before = u.len(); + u.retain(|x| x.name != name); + let removed = u.len() != before; + drop(u); + if removed { + clear_proof_cache(); + PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed); + } + removed +} + +/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`] +/// removes one: conversions through a profile looked up by name, such as those of a +/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same. pub fn profiles_generation() -> u64 { PROFILES_GENERATION.load(Ordering::Relaxed) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index f034d6164..91afee24c 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -39,10 +39,19 @@ pub fn specs() -> Vec { "Load Color Profile…", [], None, - "{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}", + "{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}", always, load_profile ), + cmd!( + "color.removeProfile", + "Remove Color Profile…", + [], + None, + "{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}", + always, + remove_profile + ), cmd!( "edit.assignProfile", "Assign Profile…", @@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { } #[cfg(not(target_arch = "wasm32"))] -fn load_profile(_: &mut Session, p: &Value) -> Result { +fn load_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "color.loadProfile"; let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; crate::file_access::check_read(path).map_err(EngineError::Other)?; - let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?; - Ok(json!({"name": info.name, "kind": info.kind})) + let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?; + let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?; + // The bytes are kept, not the path: the file may move or go away. + let remembered = s.store_profile(&info.name, &bytes); + Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) +} + +/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it. +fn remove_profile(s: &mut Session, p: &Value) -> Result { + const C: &str = "color.removeProfile"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?; + if info.builtin { + return Err(bad(C, format!("`{name}` is a built-in profile and stays"))); + } + let working = cms::active_settings(); + if working.rgb == name || working.cmyk == name { + return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first"))); + } + if !cms::unregister_icc(&info.name) { + return Err(bad(C, format!("no profile named `{name}`"))); + } + s.unstore_profile(&info.name); + touch_all(s); + Ok(json!({"removed": info.name})) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs index fb63257b1..faa986795 100644 --- a/crates/engine/src/cmd/pdfcmds.rs +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -18,10 +18,16 @@ pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; /// What `pdf.preset.export` writes and `pdf.preset.import` reads. #[derive(Serialize, Deserialize)] -struct PresetFile { - format: String, +pub(crate) struct PresetFile { + pub(crate) format: String, #[serde(default)] - presets: Vec, + pub(crate) presets: Vec, +} + +impl PresetFile { + pub(crate) fn new(presets: Vec) -> Self { + Self { format: PRESET_FORMAT.into(), presets } + } } pub fn specs() -> Vec { @@ -112,6 +118,7 @@ fn save(s: &mut Session, p: &Value) -> Result { } let at = s.pdf_saved_index(&name); let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned(); + let old_name = saved.as_ref().map(|old| old.name.clone()); // A saved preset changes from its own settings unless `preset` says where to start. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let (Some(old), None) = (&saved, str_param(p, "preset")) { @@ -127,6 +134,7 @@ fn save(s: &mut Session, p: &Value) -> Result { }; let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default(); let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() }; + s.store_pdf_preset(old_name.as_deref(), &preset); match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) { Some(slot) => *slot = preset, None => s.prefs.pdf_presets.push(preset), @@ -141,7 +149,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("`{name}` is a built-in preset and stays"))); } let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?; - Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name })) + let deleted = s.prefs.pdf_presets.remove(i).name; + s.unstore_pdf_preset(&deleted); + Ok(json!({ "deleted": deleted })) } fn export(s: &mut Session, p: &Value) -> Result { @@ -196,12 +206,14 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(slot) = s.prefs.pdf_presets.get_mut(i) { preset.name = slot.name.clone(); imported.push(preset.name.clone()); - *slot = preset; + *slot = preset.clone(); + s.store_pdf_preset(Some(&preset.name), &preset); } } None => { preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None)); imported.push(preset.name.clone()); + s.store_pdf_preset(None, &preset); s.prefs.pdf_presets.push(preset); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 30ffda9f4..15ade244c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod journal; pub mod link_watch; pub mod namematch; pub mod steps; +pub mod store; mod tooling; pub mod units; @@ -1176,6 +1177,12 @@ pub struct Session { pub style_libraries: cmd::stylelib::Libraries, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, + /// Where the saved PDF presets are kept, one file each; `None`: only for the session + /// ([`Session::attach_pdf_preset_store`]). + pdf_preset_dir: Option, + /// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process + /// ([`Session::attach_profile_store`]). + profile_dir: Option, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, /// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame; @@ -1268,6 +1275,8 @@ impl Session { freeform_point: None, style_libraries: Default::default(), libraries: Default::default(), + pdf_preset_dir: None, + profile_dir: None, recent_urls: vec![], ui_language: None, journal_note: Default::default(), @@ -2130,6 +2139,8 @@ mod tests_slices; #[cfg(test)] mod tests_smartguides; #[cfg(test)] +mod tests_store; +#[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokegradient; diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs new file mode 100644 index 000000000..9de359b44 --- /dev/null +++ b/crates/engine/src/store.rs @@ -0,0 +1,211 @@ +//! The user's own PDF presets and ICC profiles, kept in folders next to the preferences so that +//! they outlive the process (#2168, #2174). The apps decide where ([`Session::attach_pdf_preset_store`], +//! [`Session::attach_profile_store`]); a session nobody attached a folder to keeps nothing, which +//! is how the engine's own tests and embedders run. +//! +//! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes, +//! so a file can also be imported by hand), named after the preset. A session that attaches the +//! folder replaces its saved presets with the ones in it; a folder that doesn't exist yet takes +//! the presets the session has (the ones an older version kept in the preferences file). +//! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files +//! named after the profile, never the path they came from (a profile in a system folder may +//! move or go away). Attaching the folder registers them again. +//! +//! The files are untrusted input like any other: sizes and counts are capped, a file that does not +//! parse is skipped with a log record, and every write is atomic. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use vectorcraft_pdf::{PdfPreset, builtin_preset}; + +use crate::Session; +use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile}; + +/// Files read from one store folder, at most (the rest are ignored). +pub const MAX_STORE_FILES: usize = 1000; +/// The largest PDF presets file read (a preset is a few kilobytes). +pub const MAX_PRESET_FILE: u64 = 1 << 20; +/// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB). +pub const MAX_PROFILE_FILE: u64 = 32 << 20; +/// The extension of a stored ICC profile. +const PROFILE_EXT: &str = "icc"; + +/// The file name (without extension) a stored item called `name` gets: its name made safe for any +/// file system, then a hash of the name, so that names that differ only in characters a file name +/// can't hold, or in case, can't share a file. Names equal in any case are one item. +pub(crate) fn file_stem(name: &str) -> String { + let safe: String = name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).take(48).collect(); + // FNV-1a over the lower-cased name: stable across versions and platforms, unlike `DefaultHasher`. + let hash = name.trim().to_lowercase().bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + format!("{}-{hash:016x}", safe.trim()) +} + +/// The regular files of `dir` with the extension `ext` (any case), by file name, at most +/// [`MAX_STORE_FILES`]; none when the folder can't be read. +fn list_files(dir: &Path, ext: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { return vec![] }; + let mut files: Vec = entries + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) && p.is_file()) + .collect(); + files.sort_by_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase())); + files.truncate(MAX_STORE_FILES); + files +} + +/// The content of `path` when it is at most `max` bytes; the file is never read past `max`. +pub(crate) fn read_capped(path: &Path, max: u64) -> Result, String> { + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mut bytes = vec![]; + file.take(max + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + if bytes.len() as u64 > max { + return Err(too_large(max)); + } + Ok(bytes) +} + +/// What a file over its cap is told (a detail after the file's name, in the log or an error). +fn too_large(max: u64) -> String { + format!("larger than {max} bytes") +} + +/// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them +/// already. A failure is logged: remembering is best effort and never fails the command. +fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool { + let path = dir.join(file); + if std::fs::read(&path).is_ok_and(|old| old == bytes) { + return true; + } + let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes)); + if let Err(e) = &written { + log::warn!("can't save {}: {e}", path.display()); + } + written.is_ok() +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + vectorcraft_format::write_atomic(path, bytes) +} + +/// The browser has no folders to keep anything in (the apps there never attach a store). +#[cfg(target_arch = "wasm32")] +fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system")) +} + +/// Remove `dir/file`; a file that isn't there is what was wanted. +fn take(dir: &Path, file: &str) { + let path = dir.join(file); + if let Err(e) = std::fs::remove_file(&path) + && e.kind() != std::io::ErrorKind::NotFound + { + log::warn!("can't remove {}: {e}", path.display()); + } +} + +impl Session { + /// Keep the PDF presets this session saves in `dir`, and take the ones already there as the + /// saved presets (see the module docs). Call it once, after the preferences were restored. + pub fn attach_pdf_preset_store(&mut self, dir: PathBuf) { + if dir.is_dir() { + self.prefs.pdf_presets = load_presets(&dir); + self.pdf_preset_dir = Some(dir); + } else { + self.pdf_preset_dir = Some(dir); + for preset in self.prefs.pdf_presets.clone() { + self.store_pdf_preset(None, &preset); + } + } + } + + /// Write the saved preset `preset` to the store, which held it as `old_name` when that is set + /// (a rename). Without a store, nothing happens. + pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) { + let Some(dir) = &self.pdf_preset_dir else { return }; + let mut clean = preset.clone(); + clean.settings.clear_passwords(); + let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name)); + if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))) + && old != file + { + take(dir, &old); + } + match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) { + Ok(bytes) => { + put(dir, &file, &bytes); + } + Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name), + } + } + + /// Remove the preset `name` from the store. + pub(crate) fn unstore_pdf_preset(&self, name: &str) { + if let Some(dir) = &self.pdf_preset_dir { + take(dir, &format!("{}.{PRESET_FORMAT}", file_stem(name))); + } + } + + /// Keep the ICC profiles this session loads in `dir`, and register the ones already there. + /// Call it before anything that names a profile (restoring the preferences, the first command). + pub fn attach_profile_store(&mut self, dir: PathBuf) { + for path in list_files(&dir, PROFILE_EXT) { + let loaded = + read_capped(&path, MAX_PROFILE_FILE).and_then(|bytes| vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string())); + if let Err(e) = loaded { + log::warn!("skipping the stored colour profile {}: {e}", path.display()); + } + } + self.profile_dir = Some(dir); + } + + /// Write the profile `name` (its file's `bytes`) to the store; `false` when it didn't go there. + pub(crate) fn store_profile(&self, name: &str, bytes: &[u8]) -> bool { + self.profile_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)), bytes)) + } + + /// Remove the profile `name` from the store. + pub(crate) fn unstore_profile(&self, name: &str) { + if let Some(dir) = &self.profile_dir { + take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name))); + } + } +} + +/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a +/// built-in preset, at most [`MAX_STORE_FILES`]. +fn load_presets(dir: &Path) -> Vec { + let mut presets: Vec = vec![]; + for path in list_files(dir, PRESET_FORMAT) { + let file = read_capped(&path, MAX_PRESET_FILE).and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| e.to_string())); + let file = match file { + Ok(f) if f.format == PRESET_FORMAT => f, + Ok(f) => { + log::warn!("skipping {}: not a PDF presets file (format `{}`)", path.display(), f.format); + continue; + } + Err(e) => { + log::warn!("skipping the stored PDF preset {}: {e}", path.display()); + continue; + } + }; + for mut preset in file.presets { + let name = preset.name.trim().to_string(); + let usable = !name.is_empty() + && builtin_preset(&name).is_none() + && !presets.iter().any(|q| q.name.eq_ignore_ascii_case(&name)) + && preset.settings.check_values().is_ok() + && presets.len() < MAX_STORE_FILES; + if usable { + preset.name = name; + preset.settings.clear_passwords(); + presets.push(preset); + } else { + log::warn!("skipping the PDF preset `{name}` of {}", path.display()); + } + } + } + presets +} diff --git a/crates/engine/src/tests_store.rs b/crates/engine/src/tests_store.rs new file mode 100644 index 000000000..f630d78eb --- /dev/null +++ b/crates/engine/src/tests_store.rs @@ -0,0 +1,128 @@ +//! The stores of PDF presets and ICC profiles (#2168, #2174): what one session saves, the next one +//! finds; damaged or oversized files are skipped, never fatal. + +use serde_json::{Value, json}; +use vectorcraft_color::cms; + +use crate::Session; +use crate::store::{MAX_PRESET_FILE, file_stem}; + +fn fresh_dir(tag: &str) -> std::path::PathBuf { + let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}")); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +fn saved_names(s: &mut Session) -> Vec { + let v = s.execute("pdf.preset.list", &json!({})).unwrap(); + v["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() { + let dir = fresh_dir("presets").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Press", "description": "for the printer", "preset": "Press Quality"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Screen"})).unwrap(); + a.execute("pdf.preset.save", &json!({"name": "Old"})).unwrap(); + a.execute("pdf.preset.delete", &json!({"name": "Old"})).unwrap(); + + let mut b = Session::new(); + b.attach_pdf_preset_store(dir.clone()); + assert_eq!(saved_names(&mut b), ["Press", "Screen"]); + let press = b.prefs.pdf_presets.iter().find(|p| p.name == "Press").unwrap(); + assert_eq!(press.description, "for the printer"); + assert_eq!(press.settings, b.pdf_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings); + // The file of a name that is gone is gone. + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); + assert!(!dir.join(format!("{}.vcpdfpresets", file_stem("Old"))).exists()); +} + +#[test] +fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() { + let dir = fresh_dir("seed").join("PDF Presets"); + let mut a = Session::new(); + a.execute("pdf.preset.save", &json!({"name": "From Preferences"})).unwrap(); + a.attach_pdf_preset_store(dir.clone()); + assert!(dir.is_dir(), "written at once"); + let mut b = Session::new(); + b.attach_pdf_preset_store(dir); + assert_eq!(saved_names(&mut b), ["From Preferences"]); +} + +#[test] +fn damaged_unsafe_and_oversized_preset_files_are_skipped() { + let dir = fresh_dir("damaged").join("PDF Presets"); + let mut a = Session::new(); + a.attach_pdf_preset_store(dir.clone()); + a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap(); + let write = |name: &str, bytes: &[u8]| std::fs::write(dir.join(name), bytes).unwrap(); + write("a-not-json.vcpdfpresets", b"{ not json"); + write("b-other-format.vcpdfpresets", br#"{"format": "something else", "presets": []}"#); + let huge = vec![b' '; MAX_PRESET_FILE as usize + 1]; + write("c-huge.vcpdfpresets", &huge); + write("d-readme.txt", b"ignored"); + let good = std::fs::read_to_string(dir.join(format!("{}.vcpdfpresets", file_stem("Good")))).unwrap(); + // A built-in name, an empty name, a second "good" in another case, and values no export accepts. + let mut file: Value = serde_json::from_str(&good).unwrap(); + let mut preset = file["presets"][0].clone(); + for name in ["High Quality Print", " ", "GOOD"] { + preset["name"] = json!(name); + file["presets"].as_array_mut().unwrap().push(preset.clone()); + } + preset["name"] = json!("Bad Values"); + preset["settings"]["advanced"]["fontSubsetPercent"] = json!(500); + file["presets"].as_array_mut().unwrap().push(preset); + write("e-mixed.vcpdfpresets", file.to_string().as_bytes()); + + let mut b = Session::new(); + b.attach_pdf_preset_store(dir); + assert_eq!(saved_names(&mut b), ["Good"]); +} + +/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a +/// name of the same length). +fn icc_named(name: &str) -> Vec { + let builtin = cms::DISPLAY_P3; + assert_eq!(builtin.len(), name.len()); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec(); + let (from, to) = (utf16(builtin), utf16(name)); + let at = bytes.windows(from.len()).position(|w| w == from).unwrap(); + bytes[at..at + to.len()].copy_from_slice(&to); + bytes +} + +#[test] +fn stored_profiles_register_again_and_removing_one_removes_its_copy() { + let dir = fresh_dir("profiles").join("Color Profiles"); + std::fs::create_dir_all(&dir).unwrap(); + let name = "StoreRGB-1"; + let file = format!("{}.icc", file_stem(name)); + std::fs::write(dir.join("junk.icc"), b"not a profile").unwrap(); + std::fs::write(dir.join("empty.icc"), b"").unwrap(); + std::fs::write(dir.join(&file), icc_named(name)).unwrap(); + assert!(cms::profile(name).is_none()); + + let mut s = Session::new(); + s.attach_profile_store(dir.clone()); + assert!(cms::profile(name).is_some_and(|p| !p.builtin), "registered from the store"); + + // A profile loaded from elsewhere is copied in, whatever the file is called. + let other = "StoreRGB-2"; + let loose = vectorcraft_testkit::temp_dir("store-loose").join("whatever.icm"); + std::fs::write(&loose, icc_named(other)).unwrap(); + let r = s.execute("color.loadProfile", &json!({"path": loose.to_str().unwrap()})).unwrap(); + assert_eq!((r["name"].as_str(), r["remembered"].as_bool()), (Some(other), Some(true))); + std::fs::remove_file(&loose).unwrap(); + assert_eq!(std::fs::read(dir.join(format!("{}.icc", file_stem(other)))).unwrap(), icc_named(other)); + + let r = s.execute("color.removeProfile", &json!({"name": name})).unwrap(); + assert_eq!(r["removed"], name); + assert!(cms::profile(name).is_none() && !dir.join(&file).exists()); + assert!(s.execute("color.removeProfile", &json!({"name": name})).is_err(), "already gone"); + assert!(s.execute("color.removeProfile", &json!({"name": cms::SRGB})).is_err(), "built-in profiles stay"); + s.execute("color.removeProfile", &json!({"name": other})).unwrap(); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9acbb54a9..e3e8d7a51 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1559,6 +1559,7 @@ Live Paint Selection Tool Инструмент за селекция на динамично оцветяване Live Shape Properties Свойства на динамична фигура Load Color Profile… Зареждане на файлов цвят… + Remove Color Profile… Премахване на цветови профил… Load Image into Clipboard Зареждане на изображение в клипборда Load Metafile into Clipboard Зареждане на метафайл в клипборда Load PDF into Clipboard Зареждане на PDF в клипборда diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d30d0166c..979004bf3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2864,6 +2864,7 @@ theme Appearance Motiv Convert Document Color Mode Převést barevný režim dokumentu Gamut Warning Upozornění na gamut Load Color Profile… Načíst barevný profil… + Remove Color Profile… Odebrat barevný profil… Plates Desky Spot Color Přímá barva Add to Swatches Přidat do vzorníku diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 92074af90..fdbab8116 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1584,6 +1584,7 @@ Live Paint Selection Tool Interaktiv-malen-Auswahlwerkzeug Live Shape Properties Eigenschaften interaktiver Formen Load Color Profile… Farbprofil laden… + Remove Color Profile… Farbprofil entfernen… Load Image into Clipboard Bild in die Zwischenablage laden Load Metafile into Clipboard Metadatei in die Zwischenablage laden Load PDF into Clipboard PDF in die Zwischenablage laden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5cc3023d9..608c2749f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2319,6 +2319,7 @@ theme Appearance Εμφάνιση Live Paint Selection Tool Επιλογή δυναμικής βαφής (Live Paint Selection Tool) Live Shape Properties Ιδιότητες δυναμικού σχήματος Load Color Profile… Φόρτωση προφίλ χρώματος… + Remove Color Profile… Αφαίρεση προφίλ χρώματος… Load Image into Clipboard Φόρτωση εικόνας στο πρόχειρο Load Metafile into Clipboard Φόρτωση μετααρχείου στο πρόχειρο Load PDF into Clipboard Φόρτωση PDF στο πρόχειρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ce0da6fe8..282e5dd11 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1600,6 +1600,7 @@ Live Paint Selection Tool Herramienta Selección de pintura interactiva Live Shape Properties Propiedades de forma dinámica Load Color Profile… Cargar perfil de color… + Remove Color Profile… Quitar perfil de color… Load Image into Clipboard Cargar imagen en el portapapeles Load Metafile into Clipboard Cargar metarchivo en el portapapeles Load PDF into Clipboard Cargar PDF en el portapapeles diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 4e5bb0b35..a0fd4b8c1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2814,6 +2814,7 @@ theme Appearance Ulkoasu Convert Document Color Mode Muunna asiakirjan väritila Gamut Warning Väriavaruusvaroitus Load Color Profile… Lataa väriprofiili… + Remove Color Profile… Poista väriprofiili… Plates Erottelut Spot Color Spottiväri Add to Swatches Lisää värimalleihin diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6c011a2ce..344b340dc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1599,6 +1599,7 @@ Live Paint Selection Tool Outil Sélection de peinture dynamique Live Shape Properties Propriétés de la forme dynamique Load Color Profile… Charger un profil colorimétrique… + Remove Color Profile… Supprimer un profil colorimétrique… Load Image into Clipboard Charger une image dans le presse-papiers Load Metafile into Clipboard Charger un métafichier dans le presse-papiers Load PDF into Clipboard Charger un PDF dans le presse-papiers diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27a6eb2b5..cf747808f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2023,6 +2023,7 @@ Live Paint Selection Tool Élőfestés-kijelölés Live Shape Properties Élő alakzat tulajdonságai Load Color Profile… Színprofil betöltése… + Remove Color Profile… Színprofil eltávolítása… Load Image into Clipboard Kép betöltése a vágólapra Load Metafile into Clipboard Metafájl betöltése a vágólapra Load PDF into Clipboard PDF betöltése a vágólapra diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7cffb8995..fe5f1a4dc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1602,6 +1602,7 @@ Live Paint Selection Tool Strumento Selezione pittura dinamica Live Shape Properties Proprietà forma dinamica Load Color Profile… Carica profilo colore… + Remove Color Profile… Rimuovi profilo colore… Load Image into Clipboard Carica immagine negli appunti Load Metafile into Clipboard Carica metafile negli appunti Load PDF into Clipboard Carica PDF negli appunti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abe633da1..af96e8078 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1781,6 +1781,7 @@ Live Paint Selection Tool ライブペイント選択ツール Live Shape Properties ライブシェイプのプロパティ Load Color Profile… カラープロファイルを読み込む… + Remove Color Profile… カラープロファイルを削除… Load Image into Clipboard 画像をクリップボードに読み込む Load Metafile into Clipboard メタファイルをクリップボードに読み込む Load PDF into Clipboard PDF をクリップボードに読み込む diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2d36be8b6..4268d7378 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1776,6 +1776,7 @@ Live Paint Selection Tool 라이브 페인트 선택 도구 Live Shape Properties 라이브 모양 속성 Load Color Profile… 색상 프로파일 불러오기… + Remove Color Profile… 색상 프로파일 제거… Load Image into Clipboard 이미지를 클립보드로 불러오기 Load Metafile into Clipboard 메타파일을 클립보드로 불러오기 Load PDF into Clipboard PDF를 클립보드로 불러오기 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08b10404f..ba1a49344 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2735,6 +2735,7 @@ theme Appearance Wygląd Convert Document Color Mode Konwertuj tryb koloru dokumentu Gamut Warning Ostrzeżenie gamutu Load Color Profile… Wczytaj profil kolorów… + Remove Color Profile… Usuń profil kolorów… Plates Płyty Spot Color Kolor dodatkowy Add to Swatches Dodaj do próbek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4fe9c2ca5..32bf7d118 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1341,6 +1341,7 @@ Live Paint Selection Tool Ferramenta Seleção de Pintura ao Vivo Live Shape Properties Propriedades de Forma ao Vivo Load Color Profile… Carregar Perfil de Cor… + Remove Color Profile… Remover Perfil de Cor… Load Image into Clipboard Carregar Imagem na Área de Transferência Load Metafile into Clipboard Carregar Metarquivo na Área de Transferência Load PDF into Clipboard Carregar PDF na Área de Transferência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c3ca047a8..8d1287235 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1600,6 +1600,7 @@ Live Paint Selection Tool Инструмент «Выделение быстрой заливки» Live Shape Properties Свойства динамической фигуры Load Color Profile… Загрузить профиль цвета… + Remove Color Profile… Удалить профиль цвета… Load Image into Clipboard Загрузить изображение в буфер обмена Load Metafile into Clipboard Загрузить метафайл в буфер обмена Load PDF into Clipboard Загрузить PDF в буфер обмена diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 367aeab82..713a977d0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1559,6 +1559,7 @@ Live Paint Selection Tool Markeringsverktyg för direktmålning Live Shape Properties Egenskaper för direktform Load Color Profile… Läs in färgprofil… + Remove Color Profile… Ta bort färgprofil… Load Image into Clipboard Läs in bild till urklipp Load Metafile into Clipboard Läs in metafil till urklipp Load PDF into Clipboard Läs in PDF till urklipp diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 22c386082..5d64483ec 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1596,6 +1596,7 @@ Live Paint Selection Tool Інструмент «Виділення швидкої заливки» Live Shape Properties Властивості динамічної фігури Load Color Profile… Завантажити профіль кольору… + Remove Color Profile… Видалити профіль кольору… Load Image into Clipboard Завантажити зображення в буфер обміну Load Metafile into Clipboard Завантажити метафайл у буфер обміну Load PDF into Clipboard Завантажити PDF у буфер обміну diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 62232418c..dd552e948 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1833,6 +1833,7 @@ Live Paint Selection Tool Công cụ Chọn Tô trực tiếp Live Shape Properties Thuộc tính hình dạng trực tiếp Load Color Profile… Tải hồ sơ màu… + Remove Color Profile… Xóa hồ sơ màu… Load Image into Clipboard Tải ảnh vào bảng tạm Load Metafile into Clipboard Tải metafile vào bảng tạm Load PDF into Clipboard Tải PDF vào bảng tạm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3e5782fd2..251768913 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1499,6 +1499,7 @@ Live Paint Selection Tool 实时上色选择工具 Live Shape Properties 实时形状属性 Load Color Profile… 载入颜色配置文件… + Remove Color Profile… 移除颜色配置文件… Load Image into Clipboard 将图像载入剪贴板 Load Metafile into Clipboard 将图元文件载入剪贴板 Load PDF into Clipboard 将 PDF 载入剪贴板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f215ef7bd..23bdde637 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1574,6 +1574,7 @@ Live Paint Selection Tool 即時上色選取工具 Live Shape Properties 即時形狀內容 Load Color Profile… 載入色彩描述檔… + Remove Color Profile… 移除色彩描述檔… Load Image into Clipboard 將影像載入剪貼簿 Load Metafile into Clipboard 將中繼檔載入剪貼簿 Load PDF into Clipboard 將 PDF 載入剪貼簿 diff --git a/docs/development.md b/docs/development.md index 978fa2e4a..7627cbfb2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,7 +85,8 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`. |---|---| | `VECTORCRAFT_CONTROL_PORT` | Same as `--control `, which overrides it; a value that isn't a port from 1 to 65535 stops the app | | `VECTORCRAFT_IN_WINDOW_MENUS` | Any value but empty or `0`: same as `--in-window-menus` (see [macOS: the menu bar](#macos-the-menu-bar)) | -| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file (agents' test runs) | +| `VECTORCRAFT_NO_PREFS` | No preferences read or written, no default Data Recovery folder and no log file, and no saved PDF presets or loaded ICC profiles kept (agents' test runs; also for `vectorcraft-cli`) | +| `VECTORCRAFT_CONFIG_DIR` | The folder for the preferences and the folders next to them (`PDF Presets`, `Color Profiles`, …), for the app and `vectorcraft-cli` (see `docs/control-protocol.md`) | | `WGPU_POWER_PREF` | Graphics adapter: `low`, `high` or `none` (see [Desktop graphics processor](#desktop-graphics-processor)) | | `WGPU_ADAPTER_NAME` | Graphics adapter by (part of) its name, any case (see [Desktop graphics processor](#desktop-graphics-processor)) | | `WGPU_BACKEND` | Graphics backends: `vulkan`, `dx12`, `metal`, `gl` | diff --git a/docs/mcp.md b/docs/mcp.md index e0f2fb9e5..a1b97ef2d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -771,6 +771,27 @@ applies to the `--cmd` just before it. `run` also accepts the host commands `fil `file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads (`vectorcraft-cli --help` lists them). +### What a headless session remembers + +`vectorcraft-cli run` and the headless `mcp` server keep two things between runs, in folders next to the +preferences (the folder the desktop app uses, so both see the same ones; `VECTORCRAFT_CONFIG_DIR` moves it): + +- **Saved PDF presets** (`pdf.preset.save`, `.delete`, `.import`): one `.vcpdfpresets` file per preset in + `PDF Presets`, written as the command runs. Presets saved earlier are there at the next start and work as + `preset` in the PDF options. +- **ICC profiles** (`color.loadProfile`): a copy of the file goes into `Color Profiles` (the result says + `remembered: true`), and every profile there is registered again at the next start, before the first + command, so `edit.colorSettings {"cmyk": "…"}` or a preset's output intent can name it in a later run. The + copy is what counts: the original may move or be deleted. `color.removeProfile {name}` forgets a profile and its + copy (not while it is a working space). + +Not remembered: Color Settings, the document, anything else. Nothing is read or written when +`VECTORCRAFT_NO_PREFS` is set, and a server confined with `--automation-read-root` or +`--automation-write-root` keeps nothing either (the folders are outside its roots). A file that is damaged, too large +(a profile over 32 MB, a presets file over 1 MB) or not a valid preset or profile is skipped and logged. When the +desktop app starts, the presets it kept in its preferences file before this folder existed are moved into it; after +that the folder is the one place they live, so a preset deleted by `vectorcraft-cli` is gone in the app too. + ### Parameter keys `command_run`, each step of `command_batch`, `run --cmd … --params` and the control channel's `engine.execute` check @@ -2476,8 +2497,10 @@ newName?, description?, preset?, …document.exportPdf options}` creates or chan the preset's own settings; `newName` renames it; built-in presets are read-only; passwords are never stored); `pdf.preset.delete {name}` deletes one. `pdf.preset.export {names?, path?}` writes them as a `.vcpdfpresets` JSON file (without `path` it returns `data`), and `pdf.preset.import {path? | data? | dataBase64?, replace?}` adds a file's -presets (names in use get a number unless `replace`). Saved presets live with the preferences and work as `preset` +presets (names in use get a number unless `replace`). Saved presets work as `preset` in `document.exportPdf`, `document.pdfSettings`, `document.export`/`serialize` with format `pdf` and `.ai` saves. +They are kept across restarts, in the desktop app and in `vectorcraft-cli` alike (see +[What a headless session remembers](#what-a-headless-session-remembers)). ```json {"name":"run_command","arguments":{"command":"pdf.preset.save","params":{"name":"Web","preset":"Smallest File Size","compatibility":"1.5"}}} From d55b4f77cfb38f8ef979f05d317d0a9c7b43fc13 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:49:24 +0700 Subject: [PATCH 062/184] AI import: cite the published format specification by its title --- crates/eps/src/import/ai/read.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 95cad73c7..45808b2cb 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1131,8 +1131,8 @@ impl<'a> Reader<'a> { } "d" => self.dash(&vals), "D" => { - // The published format §6.8 permits `u` groups of compound members: - // https://www.moon-soft.com/program/FORMAT/graphics/ai30.pdf + // The published Adobe Illustrator File Format Specification (version 3.0), §6.8, + // permits `u` groups of compound members. // Their explicit winding declarations describe subpaths, not separate paints; // modern streams can also end these groups with `9 () XW`. let n = self.frames.len(); From 1eb9bea82aab4e0d3b2fee03b86fb80e039d3721 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:27:31 +0200 Subject: [PATCH 063/184] Inspect: scope document.inspect / doc_inspect by ids, kind, limit and offset (#2167) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 22 +++++- crates/engine/src/inspect.rs | 106 ++++++++++++++++++++++++++- crates/engine/src/tests.rs | 59 +++++++++++++++ crates/engine/tests/command_sweep.rs | 2 + crates/mcp/src/tests.rs | 7 ++ crates/mcp/src/tools.rs | 30 +++++--- docs/control-protocol.md | 2 +- docs/mcp.md | 13 +++- 8 files changed, 223 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c0eff9f4..7555d7809 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { ), cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close), cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate), - cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find), cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), @@ -172,6 +172,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result { if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } } +/// Most node ids `document.inspect {ids}` takes. +const MAX_INSPECT_IDS: usize = 10_000; + +fn doc_inspect(s: &mut Session, p: &Value) -> Result { + const C: &str = "document.inspect"; + let opts = slice_opts(p, C)?; + let ids = match p.get("ids") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))), + Some(_) => Some(checked_ids_param(s, p, "ids", C)?), + }; + let kind = match p.get("kind") { + None | Some(Value::Null) => None, + Some(Value::String(k)) => Some(k.clone()), + Some(_) => return Err(bad(C, "`kind` must be a string")), + }; + let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) }; + Ok(inspect::document_scoped(s, opts, &scope)) +} + /// `depth` / `childLimit` for a summary read: absent (or null) means no limit. fn slice_opts(p: &Value, cmd: &str) -> Result { Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false }) diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index c5fe27670..1850e5922 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -131,9 +131,113 @@ pub fn document(s: &Session) -> Value { /// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and /// the rest always come whole): `depth: 0` is the skeleton — top layers with counts. pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value { + document_scoped(s, opts, &Scope::default()) +} + +/// Which nodes `document.inspect` lists in place of the whole layer tree. +#[derive(Clone, Debug, Default)] +pub struct Scope { + /// Only these nodes (any level, already checked to exist), in the order given. + pub ids: Option>, + /// Only nodes of this panel label (case-insensitive), searched in the subtrees of `ids` + /// or else of every layer. + pub kind: Option, + /// At most this many entries after `offset` (`None`: all). + pub limit: Option, + /// Entries skipped first. + pub offset: u64, +} + +impl Scope { + fn scoped(&self) -> bool { + self.ids.is_some() || self.kind.is_some() + } +} + +/// A node with the ids of its ancestors (layer first). +type Hit<'a> = (&'a Node, Vec); + +fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec { + let mut out = Vec::new(); + let mut cur = d.parent_of(id); + // The bound keeps a malformed tree from looping. + while let Some(p) = cur.filter(|_| out.len() < 10_000) { + out.push(p.0); + cur = d.parent_of(p); + } + out.reverse(); + out +} + +fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec, out: &mut Vec>) { + if n.kind_label().eq_ignore_ascii_case(kind) { + out.push((n, path.clone())); + } + if let Some(ch) = n.children() { + path.push(n.id.0); + for c in ch.iter().rev() { + collect_kind(c, kind, path, out); + } + path.pop(); + } +} + +/// [`document_opts`] with the layer tree replaced by the nodes `scope` picks. Without `ids` +/// or `kind` they are the top layers (`layers`, paged by `limit`/`offset`); with either they +/// are `nodes` (each with its `path` of ancestor ids when picked by `kind`). A paged or scoped +/// reply also reports `total`, the entries before paging. +pub fn document_scoped(s: &Session, opts: SummaryOpts, scope: &Scope) -> Value { let Some(st) = s.active() else { return Value::Null }; let d = &st.doc; let symbols = d.symbol_boxes(); + let mut hits: Vec = Vec::new(); + match (&scope.ids, scope.kind.as_deref().filter(|k| !k.is_empty())) { + (None, None) => hits.extend(d.layers.iter().rev().map(|l| (&**l, Vec::new()))), + (Some(ids), None) => hits.extend(ids.iter().filter_map(|&i| d.node(i)).map(|n| (n, Vec::new()))), + (ids, Some(kind)) => { + let mut path = Vec::new(); + match ids { + Some(ids) => { + for n in ids.iter().filter_map(|&i| d.node(i)) { + path = ancestors(d, n.id); + collect_kind(n, kind, &mut path, &mut hits); + } + } + None => d.layers.iter().rev().for_each(|l| collect_kind(l, kind, &mut path, &mut hits)), + } + } + } + let total = hits.len(); + let skip = usize::try_from(scope.offset).unwrap_or(usize::MAX); + let take = scope.limit.map_or(usize::MAX, |l| usize::try_from(l).unwrap_or(usize::MAX)); + let by_kind = scope.kind.as_deref().is_some_and(|k| !k.is_empty()); + let nodes: Vec = hits + .into_iter() + .skip(skip) + .take(take) + .map(|(n, path)| { + let member = d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + let mut v = node_summary_opts(n, SummaryOpts { compound_member: member, ..opts }, &symbols); + if by_kind { + v["path"] = json!(path); + } + v + }) + .collect(); + let mut out = document_base(s); + if let Some(o) = out.as_object_mut() { + o.insert(if scope.scoped() { "nodes" } else { "layers" }.into(), Value::Array(nodes)); + if scope.scoped() || scope.limit.is_some() || scope.offset > 0 { + o.insert("total".into(), json!(total)); + } + } + out +} + +/// Everything `document.inspect` reports except the layer tree. +fn document_base(s: &Session) -> Value { + let Some(st) = s.active() else { return Value::Null }; + let d = &st.doc; json!({ "title": st.title(), "path": st.path, @@ -142,8 +246,6 @@ pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value { "units": d.units.label(), "colorMode": format!("{:?}", d.color_mode), "artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::>(), - // Top of the stack first, like the Layers panel. - "layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts, &symbols)).collect::>(), "currentLayer": st.active_layer.map(|l| l.0), // The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`). "layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::>(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cb2ff2147..bb61e78de 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2042,6 +2042,65 @@ fn document_inspect_slices_the_layer_tree() { assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err()); } +/// `document.inspect` scoped by `ids`, `kind`, `limit` and `offset`: each entry is the node as +/// the full tree lists it, bad input is an error, and the unscoped reply keeps its shape. +#[test] +fn document_inspect_scopes_by_ids_kind_and_page() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0; + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0).0; + s.execute("select.all", &json!({})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0).0; + let mut ins = |p: Value| s.execute("document.inspect", &p).unwrap(); + + let full = ins(json!({})); + assert!(full.get("nodes").is_none() && full.get("total").is_none(), "{full}"); + let layer = full["layers"][0].clone(); + let layer_id = layer["id"].as_u64().unwrap(); + let find = |tree: &Value, id: u64| -> Value { + fn walk(n: &Value, id: u64) -> Option { + if n["id"] == id { + return Some(n.clone()); + } + n["children"].as_array()?.iter().find_map(|c| walk(c, id)) + } + walk(tree, id).unwrap() + }; + + // ids: just those nodes, in the order given, equal to their part of the full tree. + let some = ins(json!({"ids": [c, group]})); + assert!(some.get("layers").is_none()); + assert_eq!(some["nodes"], json!([find(&layer, c), find(&layer, group)])); + assert_eq!(some["total"], 2); + assert_eq!(some["artboards"], full["artboards"]); + assert_eq!(ins(json!({"ids": [group], "depth": 0}))["nodes"][0].get("children"), None); + + // kind: every match with its ancestor path, top of the stack first; paged. + let paths = ins(json!({"kind": "rectangle"})); + let ids: Vec = paths["nodes"].as_array().unwrap().iter().map(|n| n["id"].as_u64().unwrap()).collect(); + assert_eq!(ids, [c, b, a]); + assert_eq!(paths["nodes"][1]["path"], json!([layer_id, group])); + assert_eq!(paths["total"], 3); + let page = ins(json!({"kind": "Rectangle", "offset": 1, "limit": 1})); + assert_eq!(page["nodes"].as_array().map(Vec::len), Some(1)); + assert_eq!(page["nodes"][0]["id"], b); + assert_eq!(page["total"], 3); + assert_eq!(ins(json!({"kind": "Rectangle", "offset": 99}))["nodes"], json!([])); + assert_eq!(ins(json!({"kind": "Rectangle", "ids": [group]}))["total"], 2); + + // limit/offset alone page the top layers. + let paged = ins(json!({"limit": 0})); + assert_eq!(paged["layers"], json!([])); + assert_eq!(paged["total"], 1); + + for bad in + [json!({"ids": [999_999]}), json!({"ids": "all"}), json!({"ids": [-1]}), json!({"kind": 3}), json!({"limit": -1}), json!({"offset": 1.5})] + { + assert!(s.execute("document.inspect", &bad).is_err(), "{bad}"); + } +} + /// `document.find` searches names, kinds and type content across the whole tree and /// answers ids with ancestor paths plus the total, so capped replies stay explicit. #[test] diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 0bf31cecb..1a24e5a25 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -275,6 +275,8 @@ fn structured_junk() { ("stroke.set", json!({"weight": -5, "dash": [0, 0, 0], "miterLimit": -1})), ("stroke.set", json!({"weight": 1e308, "dash": [-1, -2], "dashOffset": 1e308})), ("stroke.set", json!({"dash": [1e-12]})), + ("document.inspect", json!({"ids": [u64::MAX, 0, 1e308], "kind": "", "limit": u64::MAX, "offset": u64::MAX})), + ("document.inspect", json!({"ids": null, "kind": {"a": 1}, "limit": -1, "offset": "x", "depth": u64::MAX})), ("transparency.set", json!({"opacity": -50})), ("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})), ("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 13c9e964b..6007ff914 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -240,6 +240,13 @@ fn headless_end_to_end() { assert_eq!(skel["artboards"], doc["artboards"]); assert_eq!(call(&mut s, 42, "inspect_document", json!({"depth": -1}))["isError"], true); + // Scoping reaches the command: one node by id equals its part of the full tree; + // an unknown id is a tool error. + let r = call(&mut s, 43, "doc_inspect", json!({"ids": [id]})); + let one: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(one["nodes"], json!([rect]), "{one}"); + assert_eq!(call(&mut s, 44, "doc_inspect", json!({"ids": [999_999]}))["isError"], true); + // Screenshot returns image content (base64 PNG) and text. let shot_path = tmp("shot.png"); let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()})); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d4f81d6a1..523693f28 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -36,6 +36,20 @@ fn num(desc: &str) -> Value { fn string(desc: &str) -> Value { json!({"type": "string", "description": desc}) } +/// The arguments `doc_inspect` / `inspect_document` pass on to `document.inspect`. +const INSPECT_KEYS: [&str; 6] = ["depth", "childLimit", "ids", "kind", "limit", "offset"]; + +/// Their schema. +fn inspect_props() -> Value { + json!({ + "depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"}, + "childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"}, + "ids": {"type": "array", "maxItems": 10000, "items": {"type": "integer", "minimum": 0}, "description": "List just these nodes (any level) as `nodes` instead of the whole layer tree; an unknown id is an error"}, + "kind": {"type": "string", "description": "List every node of this panel label (Layer, Group, Path, Type, Image, ...; ignores case) as `nodes`, each with its ancestor `path`; searches the subtrees of `ids` when given"}, + "limit": {"type": "integer", "minimum": 0, "description": "At most this many layers or nodes (the reply adds `total`)"}, + "offset": {"type": "integer", "minimum": 0, "description": "Layers or nodes skipped first (default 0)"} + }) +} fn paint_schema(what: &str) -> Value { json!({ "description": format!("{what}: \"#rrggbb\", \"none\", [r,g,b] (0..1), {{\"c\",\"m\",\"y\",\"k\"}}, {{\"gray\"}}, or a full paint.setFill params object ({{\"gradient\":…}} / {{\"swatch\":name}})"), @@ -129,8 +143,8 @@ pub fn tool_definitions() -> Vec { tool( "doc_inspect", "Inspect document", - "Summary of the active document, including artboards, layer tree, selection and history.", - obj(json!({"depth":{"type":"integer", "minimum":0},"childLimit":{"type":"integer", "minimum":0}}), &[]), + "Summary of the active document, including artboards, layer tree, selection and history. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page.", + obj(inspect_props(), &[]), true, ), tool( @@ -175,13 +189,7 @@ pub fn tool_definitions() -> Vec { "inspect_document", "Inspect document", "Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node.", - obj( - json!({ - "depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"}, - "childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"} - }), - &[], - ), + obj(inspect_props(), &[]), true, ), tool( @@ -919,9 +927,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { - // Only the slicing options reach the command, which validates them. + // Only the slicing and scope options reach the command, which validates them. let slice: Map = - a.iter().filter(|(k, _)| matches!(k.as_str(), "depth" | "childLimit")).map(|(k, v)| (k.clone(), v.clone())).collect(); + a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); j(b.call("document.inspect", Value::Object(slice))?) } "ui_inspect" | "inspect_ui" => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d8ce4596c..461d4898b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -90,7 +90,7 @@ the two can still win that race. |---|---|---| | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`). A key of `params` that the command's params description doesn't name is an error naming it and the nearest key the command takes, before anything runs (`unknown parameter `widht`; did you mean `width`?`); see [Parameter keys](mcp.md#parameter-keys) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | -| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does. Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | +| `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | | `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | diff --git a/docs/mcp.md b/docs/mcp.md index 5f95424d0..5d8f0c204 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -189,7 +189,14 @@ node (layers and groups too) reports `opacity` when it is below 1 and `blend` wh On a large document, drill instead of dumping: `inspect_document {depth: 0}` (or `run_command document.inspect {depth?, childLimit?}`, same rules) is the skeleton (artboards with top layers and counts), `run_command document.find {name?, kind?, text?, limit?}` locates nodes across the whole tree, and `document.node` reads the -interesting ones sliced. `document.find` needs at least one filter and all given filters must match; matching +interesting ones sliced. `document.inspect` and the `doc_inspect` / `inspect_document` tools also scope the +reply themselves: `ids: [id…]` lists just those nodes (any level, in the order given, an unknown id is an error, +at most 10000), `kind` lists every node of one panel label (ignoring case; within the subtrees of `ids` when both +are given) with its ancestor `path`, and `limit` / `offset` page the list. A scoped reply carries the nodes as +`nodes` instead of `layers` (each the summary the full tree gives it, sliced by `depth` / `childLimit`) plus +`total`, the entries before paging; artboards, selection and history stay whole. `limit` / `offset` alone page the +top layers (`layers` and `total`). Without these options the reply is the whole layer tree as before. +`document.find` needs at least one filter and all given filters must match; matching ignores case, `name` is a substring of the Layers panel name, `text` a substring of type content and `kind` the exact panel label (`Layer`, `Group`, `Clip Group`, `Path`, `Compound Path`, `Type`, `Image`, `Rectangle`, ...). It answers `{matches: [{id, name, kind, path}], total}` top of the stack first, where `path` is the ancestor @@ -261,7 +268,7 @@ The older names below remain listed because existing workflows use them. | `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath); failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | -| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` | +| `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it. Artboards too large to render within the allocation bound return a tool error | | `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode | @@ -302,7 +309,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr |---|---|---| | `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | -| `inspect_document` | `{depth?, childLimit?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | +| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | | `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. | From 6920adf0924de4eafb4c3031e4495cb06b3df339 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:30:28 +0200 Subject: [PATCH 064/184] PDF export: report RGB colours and images in a CMYK document (#2162) A CMYK document holding RGB colours or images separates them into CMYK on export, and dark ones come out as rich blacks. The export report now says how many there are. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export.rs | 64 +++++++++++++++++++++++++++++-- crates/pdf/src/tests_cmykblend.rs | 27 +++++++++++++ docs/gaps.md | 1 + 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d1901a7d4..1da3b3710 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1,7 +1,7 @@ //! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`. use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use craft_pdf::write::backend as krilla; @@ -147,6 +147,42 @@ fn user_unit(w: f64, h: f64) -> f64 { if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 } } +/// The RGB colours and non-CMYK images a CMYK document carries, which the export separates into +/// CMYK. Their darkest values come out as rich blacks, so the export says how many there are. +#[derive(Default)] +pub(crate) struct RgbInCmyk { + /// Distinct RGB colours, by the bits of their channels. + colours: HashSet<[u32; 3]>, + /// Keys of the raster images that aren't CMYK. + images: HashSet, +} + +impl RgbInCmyk { + fn merge(&mut self, other: RgbInCmyk) { + self.colours.extend(other.colours); + self.images.extend(other.images); + } + + /// The warning for the export report; `None` when the document has nothing of the sort. + fn warning(&self) -> Option { + let (c, i) = (self.colours.len(), self.images.len()); + if c == 0 && i == 0 { + return None; + } + let mut parts = vec![]; + if c > 0 { + parts.push(format!("{c} RGB colour{}", if c == 1 { "" } else { "s" })); + } + if i > 0 { + parts.push(format!("{i} RGB or grey image{}", if i == 1 { "" } else { "s" })); + } + Some(format!( + "this CMYK document has {}: they are converted to CMYK through the colour settings, so dark ones can come out as rich blacks on several plates; paint them with CMYK colours (Edit › Edit Colors › Convert to CMYK) for pure black ink", + parts.join(" and ") + )) + } +} + /// A PDF being written, shared by export and print: the file's configuration and metadata, then /// its pages ([`Self::page`]), each drawn by an [`Exporter`]. pub(crate) struct Writer { @@ -159,6 +195,8 @@ pub(crate) struct Writer { /// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]). lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, warnings: Vec, + /// RGB content of a CMYK document, gathered from the exporters ([`Self::absorb`]). + rgb_in_cmyk: RgbInCmyk, /// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups. pdf13: bool, } @@ -234,7 +272,15 @@ impl Writer { created: created.map(date_time), }; let pdf = write::Writer::new(settings, metadata); - Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() }) + Ok(Self { + pdf, + cmyk: out.blends_cmyk(cmyk), + out: Arc::new(out), + lab_spots: vec![], + warnings, + rgb_in_cmyk: RgbInCmyk::default(), + pdf13: set.pdf13(), + }) } /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. @@ -323,6 +369,7 @@ impl Writer { self.warnings.push(w); } } + self.rgb_in_cmyk.merge(ex.rgb_in_cmyk); for spot in ex.lab_spots { if !self.lab_spots.iter().any(|(n, _)| *n == spot.0) { self.lab_spots.push(spot); @@ -342,6 +389,7 @@ impl Writer { let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; let (bytes, more) = self.out.write_catalog(bytes)?; self.warnings.extend(more); + self.warnings.extend(self.rgb_in_cmyk.warning()); Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) } } @@ -480,6 +528,8 @@ pub(crate) struct Exporter<'a> { pub out: Arc, /// Colours are converted to the destination (printer's marks aren't). convert: bool, + /// RGB colours and images of a CMYK document drawn so far. + rgb_in_cmyk: RgbInCmyk, /// Text as glyph outlines; else the characters' fills are real text in embedded fonts. outline_text: bool, /// The fonts real text is written in, by face id, with their units per em; `None` for faces @@ -523,6 +573,7 @@ impl<'a> Exporter<'a> { transparent: None, out: Arc::default(), convert: true, + rgb_in_cmyk: RgbInCmyk::default(), outline_text: set.advanced.outline_text, fonts: HashMap::new(), layers: false, @@ -691,6 +742,9 @@ impl Exporter<'_> { /// active colour settings, so the file carries press values. fn col(&mut self, c: &Color) -> krilla::color::Color { let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; + if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) { + self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]); + } let c = self.out.color(c, self.intent, cmyk_doc, self.convert); if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) { let note = note.clone(); @@ -1641,7 +1695,11 @@ impl Exporter<'_> { let doc = self.doc; let out = self.out.clone(); let img = doc.images.get(key).and_then(|blob| { - let r = if blob.is_cmyk() { + let cmyk_image = blob.is_cmyk(); + if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { + self.rgb_in_cmyk.images.insert(key.to_string()); + } + let r = if cmyk_image { crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels()) } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs index 5655dd0e6..dd3e8183e 100644 --- a/crates/pdf/src/tests_cmykblend.rs +++ b/crates/pdf/src/tests_cmykblend.rs @@ -103,3 +103,30 @@ fn a_cmyk_page_without_blending_through_is_an_isolated_group() { assert!(!t.contains("/Itrue") && t.contains("/S/Alpha"), "grouped {grouped}: {t}"); } } + +fn rgb_warning(d: &Document) -> Option { + export_with_report(d, &PdfOptions::uncompressed()).unwrap().warnings.into_iter().find(|w| w.contains("RGB")) +} + +#[test] +fn rgb_colours_in_a_cmyk_document_are_reported_and_separated() { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.11, 0.1, 0.1))); + add(&mut d, rect(Rect::new(50.0, 0.0, 100.0, 50.0), Color::rgb(0.11, 0.1, 0.1))); + add(&mut d, rect(Rect::new(0.0, 50.0, 50.0, 100.0), Color::rgb(0.9, 0.2, 0.2))); + let w = rgb_warning(&d).expect("a warning"); + assert!(w.contains("2 RGB colours"), "{w}"); + // The colours are written as CMYK, not RGB. + let t = text(&d); + assert!(t.contains("k0") && !t.contains("rg\n"), "{t}"); +} + +#[test] +fn cmyk_colours_and_rgb_documents_have_no_rgb_warning() { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!(rgb_warning(&d), None); + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Rgb); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.1, 0.1, 0.1))); + assert_eq!(rgb_warning(&d), None); +} diff --git a/docs/gaps.md b/docs/gaps.md index 132c865f4..5d38a57e3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -335,6 +335,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G11: PDF export of a CMYK document reports its RGB colours and non-CMYK images, with a count, in the export warnings (#2162); no entry closed, estimates unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) | From 51f8582c25e434c92999f8191bf1b0555a6039be Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:32:06 +0700 Subject: [PATCH 065/184] MCP: a cancelled export keeps no partly drawn tile or raster, and its message is translated (#2165) --- crates/render/src/fx.rs | 5 ++++- crates/render/src/pattern.rs | 4 ++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + 19 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 3082d49aa..1fc0b5a03 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -1284,7 +1284,10 @@ impl Renderer { } } let image = std::sync::Arc::new(pm); - if let Some(((key, made), inputs)) = key.zip(made).zip(inputs) { + // A cancelled export drew part of the content: that raster isn't kept for later frames. + if !vectorcraft_doc::cancel::is_cancelled() + && let Some(((key, made), inputs)) = key.zip(made).zip(inputs) + { let anchor = content.reach.origin(); let p = f.view * anchor; let rel = (x0 - p.x, y0 - p.y); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 7008e1ecb..0f31a9e32 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -258,7 +258,11 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let pm = Arc::new(pm); let (sx, sy) = (w as f64 / pw, h as f64 / ph); let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc)); + // A cancelled export drew part of the tile: it isn't kept for later frames. CACHE.with(|c| { + if vectorcraft_doc::cancel::is_cancelled() { + return; + } let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c82dac4bd..d30d0166c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5001,3 +5001,4 @@ mark-style Japanese Japonské @msg no symbol named `{n}` žádný symbol s názvem `{n}` @msg a mask has more than one art block maska má více než jeden blok kresby @msg mask art ends outside a mask dictionary kresba masky končí mimo slovník masky +@msg cancelled; nothing was changed zrušeno; nic nebylo změněno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4dab81245..92074af90 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4538,3 +4538,4 @@ theme Appearance Erscheinungsbild @msg no symbol named `{n}` kein Symbol namens `{n}` @msg a mask has more than one art block eine Maske enthält mehr als einen Grafikblock @msg mask art ends outside a mask dictionary die Maskengrafik endet außerhalb eines Maskenwörterbuchs +@msg cancelled; nothing was changed abgebrochen; es wurde nichts geändert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7a7faf3ef..5cc3023d9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4953,3 +4953,4 @@ effect Points Σημεία @msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}` @msg a mask has more than one art block η μάσκα έχει περισσότερα από ένα μπλοκ γραφικών @msg mask art ends outside a mask dictionary τα γραφικά της μάσκας τελειώνουν έξω από το λεξικό της μάσκας +@msg cancelled; nothing was changed ακυρώθηκε· δεν άλλαξε τίποτα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b9a77e07..ce0da6fe8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4943,3 +4943,4 @@ mark-style Japanese Japonesas @msg no symbol named `{n}` no hay ningún símbolo llamado `{n}` @msg a mask has more than one art block una máscara tiene más de un bloque de ilustración @msg mask art ends outside a mask dictionary la ilustración de la máscara termina fuera de un diccionario de máscara +@msg cancelled; nothing was changed cancelado; no se ha cambiado nada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c56c95af8..4e5bb0b35 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4965,3 +4965,4 @@ mark-style Japanese Japanilainen @msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole @msg a mask has more than one art block maskissa on useampi kuin yksi grafiikkalohko @msg mask art ends outside a mask dictionary maskin grafiikka päättyy maskin sanakirjan ulkopuolelle +@msg cancelled; nothing was changed peruutettu; mitään ei muutettu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 747fdce72..6c011a2ce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5000,3 +5000,4 @@ theme Appearance Apparence @msg no symbol named `{n}` aucun symbole nommé `{n}` @msg a mask has more than one art block un masque contient plusieurs blocs graphiques @msg mask art ends outside a mask dictionary les éléments graphiques du masque se terminent en dehors d’un dictionnaire de masque +@msg cancelled; nothing was changed annulé ; rien n'a été modifié diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d3de3b4f7..27a6eb2b5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4968,3 +4968,4 @@ imageTrace Outline Körvonal @msg no symbol named `{n}` nincs `{n}` nevű szimbólum @msg a mask has more than one art block egy maszk egynél több grafikai blokkot tartalmaz @msg mask art ends outside a mask dictionary a maszk grafikája a maszk szótárán kívül ér véget +@msg cancelled; nothing was changed megszakítva; semmi sem változott diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf158f674..7cffb8995 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4578,3 +4578,4 @@ lighting Light Luce @msg no symbol named `{n}` nessun simbolo denominato `{n}` @msg a mask has more than one art block una maschera contiene più di un blocco grafico @msg mask art ends outside a mask dictionary la grafica della maschera termina fuori da un dizionario di maschera +@msg cancelled; nothing was changed annullato; non è stato modificato nulla diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1e7363b0d..abe633da1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4619,3 +4619,4 @@ lighting Light ライト @msg no symbol named `{n}` `{n}` という名前のシンボルはありません @msg a mask has more than one art block マスクに複数のアートブロックがあります @msg mask art ends outside a mask dictionary マスクのアートがマスク辞書の外で終わっています +@msg cancelled; nothing was changed キャンセルしました。何も変更されていません diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f344bd74d..2d36be8b6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4542,3 +4542,4 @@ lighting Light 광원 @msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다 @msg a mask has more than one art block 마스크에 아트 블록이 두 개 이상 있습니다 @msg mask art ends outside a mask dictionary 마스크 아트가 마스크 사전 밖에서 끝납니다 +@msg cancelled; nothing was changed 취소됨; 변경된 내용이 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f393772ab..08b10404f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4846,3 +4846,4 @@ mark-style Japanese Japońskie @msg no symbol named `{n}` brak symbolu o nazwie `{n}` @msg a mask has more than one art block maska ma więcej niż jeden blok grafiki @msg mask art ends outside a mask dictionary grafika maski kończy się poza słownikiem maski +@msg cancelled; nothing was changed anulowano; nic nie zostało zmienione diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 52ddf636d..4fe9c2ca5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4918,3 +4918,4 @@ mark-style Japanese Japonesas @msg no symbol named `{n}` nenhum símbolo chamado `{n}` @msg a mask has more than one art block uma máscara contém mais de um bloco de arte @msg mask art ends outside a mask dictionary a arte da máscara termina fora de um dicionário de máscara +@msg cancelled; nothing was changed cancelado; nada foi alterado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d7967aeba..c3ca047a8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4574,3 +4574,4 @@ lighting Light Свет @msg no symbol named `{n}` нет символа с именем `{n}` @msg a mask has more than one art block маска содержит более одного блока графики @msg mask art ends outside a mask dictionary графика маски заканчивается за пределами словаря маски +@msg cancelled; nothing was changed отменено; ничего не изменено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 1f623cfbc..367aeab82 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4957,3 +4957,4 @@ theme Appearance Utseende @msg no symbol named `{n}` ingen symbol med namnet `{n}` @msg a mask has more than one art block en mask innehåller mer än ett grafikblock @msg mask art ends outside a mask dictionary maskens grafik slutar utanför en maskordlista +@msg cancelled; nothing was changed avbrutet; inget ändrades diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 509f2507f..22c386082 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4558,3 +4558,4 @@ lighting Light Світло @msg no symbol named `{n}` немає символу з назвою `{n}` @msg a mask has more than one art block маска містить більше одного блоку графіки @msg mask art ends outside a mask dictionary графіка маски закінчується за межами словника маски +@msg cancelled; nothing was changed скасовано; нічого не змінено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 250c3e2bc..62232418c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4558,3 +4558,4 @@ theme Appearance Giao diện @msg no symbol named `{n}` không có biểu tượng nào tên `{n}` @msg a mask has more than one art block mặt nạ có nhiều hơn một khối đồ họa @msg mask art ends outside a mask dictionary đồ họa mặt nạ kết thúc bên ngoài từ điển mặt nạ +@msg cancelled; nothing was changed đã hủy; không có gì thay đổi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc23a1fcf..3e5782fd2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4106,3 +4106,4 @@ lighting Light 光源 Movie Clip 影片剪辑 @msg name is empty 名称为空 @msg no symbol named `{n}` 没有名为 `{n}` 的符号 +@msg cancelled; nothing was changed 已取消;未做任何更改 From a71f8eb56a989975c3e8d92dab1345362f5ab002 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:48:49 +0700 Subject: [PATCH 066/184] Persistence: startup reads at most 256 MB of stored colour profiles (#2174) --- crates/engine/src/store.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 9de359b44..8f9712aba 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -28,6 +28,8 @@ pub const MAX_STORE_FILES: usize = 1000; pub const MAX_PRESET_FILE: u64 = 1 << 20; /// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB). pub const MAX_PROFILE_FILE: u64 = 32 << 20; +/// The most stored ICC profile bytes read at startup, all files together (the rest are skipped). +pub const MAX_PROFILE_STORE: u64 = 256 << 20; /// The extension of a stored ICC profile. const PROFILE_EXT: &str = "icc"; @@ -151,9 +153,16 @@ impl Session { /// Keep the ICC profiles this session loads in `dir`, and register the ones already there. /// Call it before anything that names a profile (restoring the preferences, the first command). pub fn attach_profile_store(&mut self, dir: PathBuf) { + let mut left = MAX_PROFILE_STORE; for path in list_files(&dir, PROFILE_EXT) { - let loaded = - read_capped(&path, MAX_PROFILE_FILE).and_then(|bytes| vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string())); + if left == 0 { + log::warn!("skipping the other stored colour profiles: they hold more than {MAX_PROFILE_STORE} bytes together"); + break; + } + let loaded = read_capped(&path, MAX_PROFILE_FILE.min(left)).and_then(|bytes| { + left -= bytes.len() as u64; + vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string()) + }); if let Err(e) = loaded { log::warn!("skipping the stored colour profile {}: {e}", path.display()); } From f0bb2760c9cab484129c1253ccb8bc361c20083f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:53:11 -0700 Subject: [PATCH 067/184] Accessibility: buttons, toggles, checkboxes and sliders have names for screen readers The controls that the shared widgets draw had no name in the accessibility tree: icon, flat, primary, secondary and split buttons, chips, toggles, checkboxes, radio buttons, tabs, sliders, the angle dial, the Reference Point's squares, the spinner and preset arrows and the searchable combo boxes. Screen readers announced each clickable one as an unnamed element of unknown role, and a disabled icon button was not in the tree. The Color Guide's library button, the Color Themes Save button and the status bar's artboard buttons also had no name. Each helper in widgets.rs except color_field now gives its response a role, a name and its state. The name is the text, label or translated tool tip the helper shows. The spinner, the preset arrow, the angle dial, the harmony wheel and the Reference Point's squares, which show none, are named "Step up or down", "Presets", "Angle", "Color wheel" and Top Left to Bottom Right. A toggle, checkbox, radio button, tab or Reference Point square reports whether it is on, an icon button reports that it is pressed while it is lit, the angle dial reports its angle, and a searchable combo box reports the text it shows. A checkbox or radio button drawn without text is named by the label beside it. The Tools panel's tool buttons, flyout rows and floating strips are named by their tools' tool tips, with the active tool pressed. The title bar's column toggle, the tear-off bars, a floating strip's close box and the Color, Gradient and None buttons are named by their tool tips. The status bar's First, Previous, Next and Last Artboard buttons now have tool tips, and the Fill and Stroke chips' tool tips are translated. The six new strings (the four artboard tips, "Color wheel" and "Step up or down") have rows in every catalog. split_button, chip_button, color_slider, channel_slider and the dialog form's slider_field and slider_rail keep their arguments. New _named variants beside them take the arrow's tool tip, the chip's tool tip or the slider's name, and every caller in the tree uses them. Docs: docs/target-app-parity.md. --- crates/ui-egui/src/chrome.rs | 27 +- crates/ui-egui/src/dialogs/brush_options.rs | 2 +- crates/ui-egui/src/dialogs/color_picker.rs | 2 +- crates/ui-egui/src/dialogs/effect.rs | 4 +- crates/ui-egui/src/dialogs/flatten.rs | 6 +- crates/ui-egui/src/dialogs/form.rs | 39 ++- crates/ui-egui/src/dialogs/freehand.rs | 2 +- crates/ui-egui/src/dialogs/path_ops.rs | 2 +- .../ui-egui/src/dialogs/perspective_grid.rs | 5 +- crates/ui-egui/src/dialogs/print.rs | 2 +- crates/ui-egui/src/dialogs/revolve.rs | 12 +- crates/ui-egui/src/dialogs/shortcut_import.rs | 2 +- crates/ui-egui/src/dialogs/slices.rs | 17 +- crates/ui-egui/src/dialogs/swatch_options.rs | 2 +- crates/ui-egui/src/dialogs/transform_each.rs | 4 +- crates/ui-egui/src/dialogs/variables.rs | 8 +- crates/ui-egui/src/i18n/bg.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fi.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sv.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/panels/align.rs | 17 + crates/ui-egui/src/panels/appearance.rs | 2 +- crates/ui-egui/src/panels/color.rs | 6 +- crates/ui-egui/src/panels/color_guide.rs | 18 +- crates/ui-egui/src/panels/color_themes.rs | 13 +- crates/ui-egui/src/panels/image_trace.rs | 3 +- crates/ui-egui/src/panels/mod.rs | 12 +- crates/ui-egui/src/panels/navigator.rs | 2 +- crates/ui-egui/src/panels/pathfinder.rs | 17 + crates/ui-egui/src/toolbar.rs | 55 +++- crates/ui-egui/src/widgets.rs | 290 +++++++++++++++++- docs/target-app-parity.md | 1 + 47 files changed, 629 insertions(+), 57 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c1eb9cb43..01a70ca97 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -322,8 +322,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() { app.ui.open_panel = Some("graphicStyles".into()); } - let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r)) - .on_hover_text(tl!("Graphic Style")); + let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| { + crate::panels::graphic_styles::paint_linked(app, ui, r) + }); egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); // Recolor Artwork, when the selection has colours to recolour. let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects)); @@ -681,16 +682,16 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0); let cur = crate::panels::artboards::selected(app, nab); let mut go = None; - for (icon, to) in [("chevrons-left", "first"), ("chevron-left", "previous")] { - if widgets::icon_button_enabled(ui, icon, "", false, cur > 0, 18.0).clicked() { + for (icon, to, tip) in [("chevrons-left", "first", tl!("First Artboard")), ("chevron-left", "previous", tl!("Previous Artboard"))] { + if widgets::icon_button_enabled(ui, icon, tip, false, cur > 0, 18.0).clicked() { go = Some(json!(to)); } } if let Some(i) = artboard_list(app, ui, cur) { go = Some(json!(i)); } - for (icon, to) in [("chevron-right", "next"), ("chevrons-right", "last")] { - if widgets::icon_button_enabled(ui, icon, "", false, cur + 1 < nab, 18.0).clicked() { + for (icon, to, tip) in [("chevron-right", "next", tl!("Next Artboard")), ("chevrons-right", "last", tl!("Last Artboard"))] { + if widgets::icon_button_enabled(ui, icon, tip, false, cur + 1 < nab, 18.0).clicked() { go = Some(json!(to)); } } @@ -1132,6 +1133,20 @@ mod tests { assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err()); } + /// Screen readers name the artboard navigator's four buttons, the ones that lead nowhere from + /// the first artboard disabled. + #[test] + fn screen_readers_name_the_artboard_navigators_buttons() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let tree = tree(|ui| super::status_bar(&mut app, ui)); + for (label, enabled) in [("First Artboard", false), ("Previous Artboard", false), ("Next Artboard", true), ("Last Artboard", true)] { + let n = named(&tree, label); + assert_eq!((n.role(), n.is_disabled()), (egui::accesskit::Role::Button, !enabled), "{label}"); + } + } + /// One headless frame of the status bar, with room above it for the artboard list; returns the /// texts painted and where they are. fn status_texts(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { diff --git a/crates/ui-egui/src/dialogs/brush_options.rs b/crates/ui-egui/src/dialogs/brush_options.rs index bc9964495..354bbb358 100644 --- a/crates/ui-egui/src/dialogs/brush_options.rs +++ b/crates/ui-egui/src/dialogs/brush_options.rs @@ -207,7 +207,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); let rail = Tokens::get(ui.ctx()).input_border; for (key, text, (lo, hi)) in BRISTLE { - widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field(ui, d, key, lo..=hi, "%", &|_| rail)); + widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field_named(ui, d, key, tl!(text), lo..=hi, "%", &|_| rail)); } ui.add_space(8.0); let p = params(d); diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 1d27072d4..ea1131bb8 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -211,7 +211,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ui.add_space(6.0); let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color); - if let (Some(z), _) = widgets::channel_slider(ui, "cp-slider", z, vec2(22.0, FIELD), &track) { + if let (Some(z), _) = widgets::channel_slider_named(ui, "cp-slider", tl!(channel.label()), z, vec2(22.0, FIELD), &track) { pick = Some(field_pick(channel, z, x, y)); } } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index c103da554..669532ef2 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -428,7 +428,7 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit widgets::subheader(ui, tl!("Options")); // Size is a share of the object's size (Relative) or a distance (Absolute). widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| { - form::slider_rail(ui, d, "size", 0.0..=100.0, &track); + form::slider_rail_named(ui, d, "size", tl!("Size:"), 0.0..=100.0, &track); if !relative { form::length(ui, d, "size", unit, FIELD_W); } else if let Some(n) = widgets::range_field(ui, ("zz", "size"), d.f64("size", 10.0), 0.0..=100.0, "%", 0, FIELD_W) { @@ -444,7 +444,7 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit } }); widgets::label_row(ui, tl!("Ridges per segment:"), LABEL_W, |ui| { - form::slider_rail(ui, d, "ridges", 0.0..=100.0, &track); + form::slider_rail_named(ui, d, "ridges", tl!("Ridges per segment:"), 0.0..=100.0, &track); if let Some(n) = widgets::range_field(ui, ("zz", "ridges"), d.f64("ridges", 4.0), 0.0..=100.0, "", 0, FIELD_W) { d.fields.insert("ridges".into(), json!(n)); } diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index 9d73431d7..194f2bf17 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -191,7 +191,11 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions ui.spacing_mut().item_spacing.x = 6.0; dim(ui, tl!("Rasters")); let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0); - if let (Some(x), _) = widgets::color_slider(ui, (id, "balance-slider"), (o.balance / 100.0) as f32, rail, &|_| t.input_border) { + if let (Some(x), _) = + widgets::color_slider_named(ui, (id, "balance-slider"), tl!("Raster/Vector Balance:"), (o.balance / 100.0) as f32, rail, &|_| { + t.input_border + }) + { o.balance = (x as f64 * 100.0).round(); } dim(ui, tl!("Vectors")); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 460eda4b2..82b3cb8db 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -218,7 +218,7 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; row_label(ui, tl!("Skew:")); ui.vertical(|ui| { - ui.horizontal(|ui| slider_field(ui, d, &k, -max..=max, "%", &|_| rail)); + ui.horizontal(|ui| slider_field_named(ui, d, &k, tl!("Skew:"), -max..=max, "%", &|_| rail)); rail_ends(ui, ends); }); ui.end_row(); @@ -315,7 +315,7 @@ pub(super) fn param_fields( }); } Value::Bool(b) => { - if crate::widgets::check(ui, "", b, true) { + if crate::widgets::check_box(ui, &humanized(&k), b, true) { d.fields.insert(k, json!(!b)); changed = true; } @@ -384,7 +384,7 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, } ParamSpec::Bool { .. } => { let b = cur.as_bool().unwrap_or(false); - crate::widgets::check(ui, "", b, true).then(|| json!(!b)) + crate::widgets::check_box(ui, &humanize(k), b, true).then(|| json!(!b)) } ParamSpec::Choice { options, .. } => { let labels: Vec<&str> = options.iter().map(String::as_str).collect(); @@ -476,11 +476,12 @@ pub(super) fn slider_w( let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text))); - slider_field(ui, d, key, range, suffix, track); + slider_field_named(ui, d, key, tl!(label), range, suffix, track); }); } /// The rail and value field of [`slider`] without its label (for a row that draws its own). +#[allow(dead_code)] pub(super) fn slider_field( ui: &mut egui::Ui, d: &mut Dialog, @@ -488,10 +489,23 @@ pub(super) fn slider_field( range: std::ops::RangeInclusive, suffix: &str, track: &dyn Fn(f32) -> egui::Color32, +) { + slider_field_named(ui, d, key, "", range, suffix, track); +} + +/// [`slider_field`] whose rail screen readers call `name` (the row's label, already translated). +pub(super) fn slider_field_named( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + name: &str, + range: std::ops::RangeInclusive, + suffix: &str, + track: &dyn Fn(f32) -> egui::Color32, ) { let (min, max) = (*range.start(), *range.end()); let v = d.f64(key, 0.0).clamp(min, max); - slider_rail(ui, d, key, range, track); + slider_rail_named(ui, d, key, name, range, track); if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) { d.fields.insert(key.into(), json!(n)); } @@ -499,10 +513,23 @@ pub(super) fn slider_field( /// The rail of [`slider_field`] alone: dragging it sets the number `d.fields[key]` to a whole /// number in `range` (a value past the range shows at its end until the rail moves). +#[allow(dead_code)] pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: std::ops::RangeInclusive, track: &dyn Fn(f32) -> egui::Color32) { + slider_rail_named(ui, d, key, "", range, track); +} + +/// [`slider_rail`] that screen readers call `name` (already translated). +pub(super) fn slider_rail_named( + ui: &mut egui::Ui, + d: &mut Dialog, + key: &str, + name: &str, + range: std::ops::RangeInclusive, + track: &dyn Fn(f32) -> egui::Color32, +) { let (min, max) = (*range.start(), *range.end()); let v = d.f64(key, 0.0).clamp(min, max); - let (dragged, _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track); + let (dragged, _) = crate::widgets::color_slider_named(ui, ("dlg-slider", key), name, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track); if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).filter(|n| *n != v) { d.fields.insert(key.into(), json!(n)); } diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index 8918cd079..f87cef445 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -71,7 +71,7 @@ fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) { let mut new = None; widgets::label_row(ui, tl!("Fidelity:"), label_w, |ui| { let at = ((v.clamp(min, max) - min) / (max - min)) as f32; - if let (Some(x), _) = widgets::color_slider(ui, "freehand-fidelity", at, form::SLIDER_WIDTH, &|_| t.input_border) { + if let (Some(x), _) = widgets::color_slider_named(ui, "freehand-fidelity", tl!("Fidelity:"), at, form::SLIDER_WIDTH, &|_| t.input_border) { new = Some(((min + x as f64 * (max - min)) * 10.0).round() / 10.0); } if let Some(x) = widgets::plain_field(ui, "freehand-fidelity-field", v, "pt", 1, 64.0) { diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs index 8bf15f7a3..162435462 100644 --- a/crates/ui-egui/src/dialogs/path_ops.rs +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -111,7 +111,7 @@ fn simplify_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { }); ui.add_space(8.0); form::row_label(ui, tl!("Corner Point Angle Threshold:")); - ui.horizontal(|ui| form::slider_field(ui, d, "angleThreshold", 0.0..=180.0, "°", &|_| rail)); + ui.horizontal(|ui| form::slider_field_named(ui, d, "angleThreshold", tl!("Corner Point Angle Threshold:"), 0.0..=180.0, "°", &|_| rail)); ui.add_space(8.0); form::check(ui, d, "straightLines", tl!("Convert to Straight Lines")); form::check(ui, d, "showOriginal", tl!("Show Original Path")); diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index b6d6af22d..5734ca7fc 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -332,8 +332,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { widgets::label_row(ui, tl!("Opacity:"), LABEL, |ui| { let v = d.f64("opacity", 50.0).clamp(0.0, 100.0); let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8); - let mut new = - widgets::color_slider(ui, "persp-opacity", (v / 100.0) as f32, form::SLIDER_WIDTH, &track).0.map(|x| (f64::from(x) * 100.0).round()); + let mut new = widgets::color_slider_named(ui, "persp-opacity", tl!("Opacity:"), (v / 100.0) as f32, form::SLIDER_WIDTH, &track) + .0 + .map(|x| (f64::from(x) * 100.0).round()); if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) { new = Some(x.round().clamp(0.0, 100.0)); } diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 41c7cbc99..ea4f58d39 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -688,7 +688,7 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { for ink in inks { let name = ink["name"].as_str().unwrap_or_default(); let on = ink["print"].as_bool().unwrap_or(true); - if widgets::check(ui, "", on, true) { + if widgets::check_box(ui, name, on, true) { set_ink(d, name, "print", json!(!on)); } let spot = if ink["spot"] == true { format!(" ({})", tl!("spot")) } else { String::new() }; diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 73f5cee55..28cb93353 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -371,10 +371,14 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, }; let rail_min = from(range.0); let rail = rail_width(ui) + VALUE_W; - let slider = - widgets::color_slider(ui, ("revolve-slider", key), ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, rail, &|_| { - t.input_border - }); + let slider = widgets::color_slider_named( + ui, + ("revolve-slider", key), + name, + ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, + rail, + &|_| t.input_border, + ); if let (Some(n), _) = slider { next = Some(rail_min + f64::from(n) * (rail_max - rail_min)); } diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index bc5de6171..218cc218f 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -835,7 +835,7 @@ fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses let (status, chord) = statuses.get(i).cloned().unwrap_or((Status::NeedsChoice, None)); let Some(r) = review.rows.get_mut(i) else { continue }; ui.horizontal(|ui| { - if widgets::check(ui, "", r.on, true) { + if widgets::check_box(ui, &r.name, r.on, true) { r.on = !r.on; } let place = crate::i18n::fmt(tl!("Line {line}"), &[("line", &r.line.to_string())]); diff --git a/crates/ui-egui/src/dialogs/slices.rs b/crates/ui-egui/src/dialogs/slices.rs index da45a477f..723f5be62 100644 --- a/crates/ui-egui/src/dialogs/slices.rs +++ b/crates/ui-egui/src/dialogs/slices.rs @@ -141,7 +141,7 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: ui.add_enabled_ui(enabled, |ui| { ui.indent(on, |ui| { ui.horizontal(|ui| { - if widgets::radio(ui, "", !by_size, enabled) { + if widgets::radio_button(ui, labels[1], !by_size, enabled) { d.fields.insert(mode.into(), json!("count")); } if let Some(n) = widgets::plain_field(ui, ("divide", count), d.f64(count, 2.0), "", 0, 50.0) { @@ -150,7 +150,7 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: widgets::dim_label(ui, labels[1]); }); ui.horizontal(|ui| { - if widgets::radio(ui, "", by_size, enabled) { + if widgets::radio_button(ui, tl!("per slice"), by_size, enabled) { d.fields.insert(mode.into(), json!("size")); } form::length(ui, d, size, app.session.general_unit(), 70.0); @@ -244,6 +244,19 @@ mod tests { assert!(l["slices"].as_array().unwrap().iter().any(|s| s["options"]["kind"] == "image" && s["source"] == "object")); } + /// Screen readers name Divide Slices' radio buttons by the text after their fields. + #[test] + fn screen_readers_name_the_divide_radio_buttons() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + use egui::accesskit::{Role, Toggled}; + let mut app = app(); + app.run("object.slice.divide", json!({})).unwrap(); + set(&mut app, "divideRows", json!(true)); + let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(role_and_state(&shown, "slices down, evenly spaced"), (Role::RadioButton, Some(Toggled::True))); + assert_eq!(role_and_state(&shown, "per slice"), (Role::RadioButton, Some(Toggled::False))); + } + #[test] fn divide_slices_cuts_by_count_or_size() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 07674ef99..51bc62f5e 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -214,7 +214,7 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text))); let v = comps.get(i).copied().unwrap_or(0.0); let track = |x: f32| crate::panels::c32(&track_color(mode, &comps, i, x)); - if let (Some(nv), _) = widgets::color_slider(ui, ("swatch-slider", i), mode.unit(i, v), 200.0, &track) { + if let (Some(nv), _) = widgets::color_slider_named(ui, ("swatch-slider", i), lbl, mode.unit(i, v), 200.0, &track) { let mut c2 = comps.clone(); c2[i] = mode.value_at(i, nv); new = Some((from_components(mode, &c2), c2)); diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index 51553051b..1d3f070cb 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -56,7 +56,7 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc widgets::subheader(ui, tl!("Scale")); for (key, label) in axes("scaleH", "scaleV") { widgets::label_row(ui, label, LABEL_W, |ui| { - form::slider_rail(ui, d, key, 0.0..=200.0, &track); + form::slider_rail_named(ui, d, key, label, 0.0..=200.0, &track); if let Some(n) = widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) { d.fields.insert(key.into(), json!(n)); } @@ -65,7 +65,7 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc widgets::subheader(ui, tl!("Move")); for (key, label) in axes("moveH", "moveV") { widgets::label_row(ui, label, LABEL_W, |ui| { - form::slider_rail(ui, d, key, -1000.0..=1000.0, &track); + form::slider_rail_named(ui, d, key, label, -1000.0..=1000.0, &track); form::length(ui, d, key, units, FIELD_W); }); } diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index ee30d81a8..12ee10020 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -212,7 +212,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { VariableKind::Graph => { text_area(ui, d, &key, 220.0); } - VariableKind::Visibility => checkbox(ui, d, &key), + VariableKind::Visibility => checkbox(ui, d, &key, &v.name), } ui.end_row(); } @@ -232,10 +232,10 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Respo r } -/// A checkbox for `key`, written back as it is toggled. -fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { +/// A checkbox for `key`, written back as it is toggled, which screen readers call `name`. +fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str) { let b = d.bool(key); - if widgets::check(ui, "", b, true) { + if widgets::check_box(ui, name, b, true) { d.fields.insert(key.into(), json!(!b)); } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e3e8d7a51..5f93a3fd1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -492,6 +492,7 @@ Color Model: Цветен модел: Color Models Цветни модели Color Picker Палитра с цвят + Color wheel Цветово колело Color Picker… Палитра с цвят… Color Reduction: Намаляване на цветовете: Color Settings Цветови настройки @@ -1175,6 +1176,10 @@ glyph {id} глиф {id} Glyphs Глифи Go to Artboard Към работна област + First Artboard Първа работна област + Previous Artboard Предишна работна област + Next Artboard Следваща работна област + Last Artboard Последна работна област Go To Link Към връзката GPL palettes keep solid colours only, as RGB. Палитрите GPL запазват само плътни цветове като RGB. GPU Performance Производителност на графичния процесор @@ -4284,6 +4289,7 @@ effect Soft Меко Reset all Нулиране на всичко Reset to default Връщане към по подразбиране Reset all Revolve settings to their defaults. Нулиране на всички настройки за въртене към техните стойности по подразбиране. + Step up or down Стъпка нагоре или надолу Drag values or labels. Double-click a value to type. Плъзнете стойности или етикети. Кликнете двукратно върху стойност за въвеждане. Drag rings to rotate. Shift snaps to 15°. Плъзнете пръстените за завъртане. Shift прилепва към 15°. Drag the light, or its rings. Shift snaps to 15°. Плъзнете светлината или нейните пръстени. Shift прилепва към 15°. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 979004bf3..6ca2a3d9c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -808,6 +808,7 @@ Reset all Obnovit vše Reset to default Obnovit výchozí hodnotu Reset all Revolve settings to their defaults. Obnovit výchozí hodnoty všech nastavení rotačního tělesa. + Step up or down O krok nahoru nebo dolů Drag values or labels. Double-click a value to type. Upravte hodnoty přetažením čísel nebo popisků. Dvojím kliknutím na hodnotu ji zadáte. Drag rings to rotate. Shift snaps to 15°. Otáčejte přetažením prstenců. Shift nastaví kroky po 15°. Drag the light, or its rings. Shift snaps to 15°. Přetáhněte světlo nebo jeho prstence. Shift nastaví kroky po 15°. @@ -855,6 +856,10 @@ lighting Light Světlo Export Keyboard Shortcuts… Exportovat klávesové zkratky… File Info Dialog Dialogové okno Informace o souboru Go to Artboard Přejít na pracovní plochu + First Artboard První pracovní plocha + Previous Artboard Předchozí pracovní plocha + Next Artboard Další pracovní plocha + Last Artboard Poslední pracovní plocha Graphic Style Options… Možnosti grafického stylu… Symbol Options Možnosti symbolu Symbol Options… Možnosti symbolu… @@ -1935,6 +1940,7 @@ lighting Light Světlo Variation Variace Color Models Barevné modely Color Picker Výběr barvy + Color wheel Barevné kolo Color Swatches Položky vzorníku Only Web Colors Jen webové barvy Original: click to restore Původní: kliknutím obnovíte diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fdbab8116..9f7ee9465 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -492,6 +492,7 @@ Color Model: Farbmodell: Color Models Farbmodelle Color Picker Farbwähler + Color wheel Farbkreis Color Picker… Farbwähler… Color Reduction: Farbreduktion: Color Settings Farbeinstellungen @@ -1183,6 +1184,10 @@ glyph {id} Glyphe {id} Glyphs Glyphen Go to Artboard Gehe zu Zeichenfläche + First Artboard Erste Zeichenfläche + Previous Artboard Vorherige Zeichenfläche + Next Artboard Nächste Zeichenfläche + Last Artboard Letzte Zeichenfläche Go To Link Gehe zu Verknüpfung GPL palettes keep solid colours only, as RGB. GPL-Paletten enthalten nur einfarbige Farben, als RGB. GPU Performance GPU-Leistung @@ -4304,6 +4309,7 @@ theme Appearance Erscheinungsbild Drag rings to rotate. Shift snaps to 15°. Ziehen Sie die Ringe, um zu drehen. Mit Shift in 15°-Schritten. Drag the light, or its rings. Shift snaps to 15°. Ziehen Sie das Licht oder seine Ringe. Mit Shift in 15°-Schritten. Drag values or labels. Double-click a value to type. Ziehen Sie Werte oder Beschriftungen. Doppelklicken Sie auf einen Wert, um ihn einzugeben. + Step up or down Schrittweise erhöhen oder verringern Intensity Intensität Keep visible surfaces only when expanding Beim Umwandeln nur sichtbare Flächen behalten Lighting Beleuchtung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 608c2749f..66977f54d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -467,6 +467,7 @@ Color Model: Μοντέλο χρώματος: Color Models Μοντέλα χρώματος Color Picker Επιλογέας χρώματος + Color wheel Χρωματικός τροχός Color Picker… Επιλογέας χρώματος… Color Reduction: Μείωση χρωμάτων: Color Settings Ρυθμίσεις χρωμάτων @@ -1138,6 +1139,10 @@ glyph {id} γλύφος {id} Glyphs Γλύφοι Go to Artboard Μετάβαση στον πίνακα σχεδίασης + First Artboard Πρώτος πίνακας σχεδίασης + Previous Artboard Προηγούμενος πίνακας σχεδίασης + Next Artboard Επόμενος πίνακας σχεδίασης + Last Artboard Τελευταίος πίνακας σχεδίασης Go To Link Μετάβαση στη σύνδεση GPL palettes keep solid colours only, as RGB. Οι παλέτες GPL διατηρούν μόνο συμπαγή χρώματα σε RGB. GPU Performance Απόδοση GPU @@ -4236,6 +4241,7 @@ theme Appearance Εμφάνιση Reset all Επαναφορά όλων Reset to default Επαναφορά στην προεπιλογή Reset all Revolve settings to their defaults. Επαναφορά όλων των ρυθμίσεων περιστροφής γύρω από άξονα στις προεπιλογές. + Step up or down Ένα βήμα πάνω ή κάτω Drag values or labels. Double-click a value to type. Σύρετε τιμές ή ετικέτες. Κάντε διπλό κλικ σε μια τιμή για πληκτρολόγηση. Drag rings to rotate. Shift snaps to 15°. Σύρετε τους δακτυλίους για περιστροφή. Με Shift γίνεται προσκόλληση ανά 15°. Drag the light, or its rings. Shift snaps to 15°. Σύρετε την πηγή φωτός ή τους δακτυλίους της. Με Shift γίνεται προσκόλληση ανά 15°. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 282e5dd11..1067afa8b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -507,6 +507,7 @@ Color Model: Modelo de color: Color Models Modelos de color Color Picker Selector de color + Color wheel Rueda de color Color Picker… Selector de color… Color Reduction: Reducción de colores: Color Settings Ajustes de color @@ -1200,6 +1201,10 @@ glyph {id} glifo {id} Glyphs Glifos Go to Artboard Ir a mesa de trabajo + First Artboard Primera mesa de trabajo + Previous Artboard Mesa de trabajo anterior + Next Artboard Mesa de trabajo siguiente + Last Artboard Última mesa de trabajo Go To Link Ir al vínculo GPL palettes keep solid colours only, as RGB. Las paletas GPL solo conservan colores sólidos, en RGB. GPU Performance Rendimiento de GPU @@ -4351,6 +4356,7 @@ axis Both Ambos Reset all Restablecer todo Reset to default Restablecer valor predeterminado Reset all Revolve settings to their defaults. Restablece todos los ajustes de revolución a sus valores predeterminados. + Step up or down Subir o bajar un paso Drag values or labels. Double-click a value to type. Arrastra los valores o las etiquetas. Haz doble clic en un valor para escribir. Drag rings to rotate. Shift snaps to 15°. Arrastra los anillos para girar. Shift ajusta a pasos de 15°. Drag the light, or its rings. Shift snaps to 15°. Arrastra la luz o sus anillos. Shift ajusta a pasos de 15°. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a0fd4b8c1..c357563e5 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -789,6 +789,7 @@ Reset all Palauta kaikki Reset to default Palauta oletus Reset all Revolve settings to their defaults. Palauta kaikki pyöräytysasetukset oletusarvoihin. + Step up or down Askel ylös tai alas Drag values or labels. Double-click a value to type. Vedä arvoja tai nimiöitä. Kirjoita arvo kaksoisnapsauttamalla. Drag rings to rotate. Shift snaps to 15°. Kierrä vetämällä renkaita. Vaihto kohdistaa 15°:n välein. Drag the light, or its rings. Shift snaps to 15°. Vedä valoa tai sen renkaita. Vaihto kohdistaa 15°:n välein. @@ -834,6 +835,10 @@ lighting Light Valo Export Keyboard Shortcuts… Vie pikanäppäimet… File Info Dialog Tiedoston tiedot -valintaikkuna Go to Artboard Siirry piirtoalustaan + First Artboard Ensimmäinen piirtoalusta + Previous Artboard Edellinen piirtoalusta + Next Artboard Seuraava piirtoalusta + Last Artboard Viimeinen piirtoalusta Graphic Style Options… Grafiikkatyylin asetukset… Symbol Options Symbolin asetukset Symbol Options… Symbolin asetukset… @@ -1896,6 +1901,7 @@ lighting Light Valo Variation Vaihtelu Color Models Värijärjestelmät Color Picker Värinvalitsin + Color wheel Väriympyrä Color Swatches Värimallit Only Web Colors Vain verkkovärit Original: click to restore Alkuperäinen: palauta napsauttamalla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 344b340dc..e74016c88 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -508,6 +508,7 @@ Color Model: Modèle colorimétrique : Color Models Modèles colorimétriques Color Picker Sélecteur de couleurs + Color wheel Roue chromatique Color Picker… Sélecteur de couleurs… Color Reduction: Réduction des couleurs : Color Settings Paramètres de couleur @@ -1199,6 +1200,10 @@ glyph {id} glyphe {id} Glyphs Glyphes Go to Artboard Atteindre le plan de travail + First Artboard Premier plan de travail + Previous Artboard Plan de travail précédent + Next Artboard Plan de travail suivant + Last Artboard Dernier plan de travail Go To Link Atteindre le lien GPL palettes keep solid colours only, as RGB. Les palettes GPL ne conservent que les couleurs unies, en RVB. GPU Performance Performances GPU @@ -4361,6 +4366,7 @@ effect Soft Doux Reset all Tout réinitialiser Reset to default Valeur par défaut Reset all Revolve settings to their defaults. Rétablir les valeurs par défaut de tous les réglages de révolution. + Step up or down Augmenter ou diminuer d’un cran Drag values or labels. Double-click a value to type. Faites glisser les valeurs ou les libellés. Double-cliquez sur une valeur pour la saisir. Drag rings to rotate. Shift snaps to 15°. Faites glisser les anneaux pour tourner. Maj contraint par pas de 15°. Drag the light, or its rings. Shift snaps to 15°. Faites glisser la lumière ou ses anneaux. Maj contraint par pas de 15°. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cf747808f..8e28c70a6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -679,6 +679,7 @@ Color Model: Színmodell: Color Models Színmodellek Color Picker Színválasztó + Color wheel Színkör Color Picker… Színválasztó… Color Reduction: Színcsökkentés: Color Settings Színbeállítások @@ -1075,6 +1076,7 @@ Drag to move Húzással áthelyezhető Drag to move out of the set Húzással kivehető a csoportból Drag to the window's left edge to dock Dokkoláshoz húzd az ablak bal szélére + Step up or down Léptetés fel vagy le Drag values or labels. Double-click a value to type. Húzd az értékeket vagy a címkéket. Gépeléshez kattints duplán egy értékre. Draw Rajzolás Draw Behind Rajzolás mögé @@ -1502,6 +1504,10 @@ glyph {id} glifa {id} Glyphs Glifák Go to Artboard Ugrás rajztáblára + First Artboard Első rajztábla + Previous Artboard Előző rajztábla + Next Artboard Következő rajztábla + Last Artboard Utolsó rajztábla Go to Conflict Ugrás az ütközésre Go To Link Ugrás a csatoláshoz Good Jó diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fe5f1a4dc..e781a57e6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -507,6 +507,7 @@ Color Model: Modello colore: Color Models Modelli colore Color Picker Selettore colore + Color wheel Ruota dei colori Color Picker… Selettore colore… Color Reduction: Riduzione colori: Color Settings Impostazioni colore @@ -1199,6 +1200,10 @@ glyph {id} glifo {id} Glyphs Glifi Go to Artboard Vai alla tavola da disegno + First Artboard Prima tavola da disegno + Previous Artboard Tavola da disegno precedente + Next Artboard Tavola da disegno successiva + Last Artboard Ultima tavola da disegno Go To Link Vai al collegamento GPL palettes keep solid colours only, as RGB. Le palette GPL conservano solo i colori pieni, in RGB. GPU Performance Prestazioni GPU @@ -4356,6 +4361,7 @@ effect Regular Normale Reset all Ripristina tutto Reset to default Ripristina valore predefinito Reset all Revolve settings to their defaults. Ripristina tutte le impostazioni di rivoluzione ai valori predefiniti. + Step up or down Aumenta o riduci di un passo Drag values or labels. Double-click a value to type. Trascina i valori o le etichette. Fai doppio clic su un valore per digitarlo. Drag rings to rotate. Shift snaps to 15°. Trascina gli anelli per ruotare. Shift usa incrementi di 15°. Drag the light, or its rings. Shift snaps to 15°. Trascina la luce o i suoi anelli. Shift usa incrementi di 15°. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index af96e8078..e925da051 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1051,6 +1051,7 @@ Color Model: カラーモデル: Color Models カラーモデル Color Picker カラーピッカー + Color wheel カラーホイール Color Picker… カラーピッカー… Color Reduction: カラー減色: Color Settings カラー設定 @@ -1513,6 +1514,10 @@ Global Process Color グローバルプロセスカラー Go To Link リンク元へ移動 Go to Artboard アートボードへ移動 + First Artboard 最初のアートボード + Previous Artboard 前のアートボード + Next Artboard 次のアートボード + Last Artboard 最後のアートボード Gradient Fill: click to apply, drag onto art グラデーションの塗り: クリックで適用、アートにドラッグ Gradient Mesh グラデーションメッシュ Gradient Tool グラデーションツール @@ -4396,6 +4401,7 @@ effect Vertical 垂直 Reset all すべてリセット Reset to default 初期値に戻す Reset all Revolve settings to their defaults. 回転体のすべての設定を初期値に戻します。 + Step up or down 1 段階ずつ増減 Drag values or labels. Double-click a value to type. 数値やラベルをドラッグして調整。数値をダブルクリックすると入力できます。 Drag rings to rotate. Shift snaps to 15°. リングをドラッグして回転。Shiftで15°刻みに調整。 Drag the light, or its rings. Shift snaps to 15°. ライトやリングをドラッグ。Shiftで15°刻みに調整。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4268d7378..c22818da9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1046,6 +1046,7 @@ Color Model: 색상 모델: Color Models 색상 모델 Color Picker 색상 피커 + Color wheel 색상환 Color Picker… 색상 피커… Color Reduction: 색상 감소: Color Settings 색상 설정 @@ -1508,6 +1509,10 @@ Global Process Color 전역 프로세스 색상 Go To Link 링크로 이동 Go to Artboard 아트보드로 이동 + First Artboard 첫 아트보드 + Previous Artboard 이전 아트보드 + Next Artboard 다음 아트보드 + Last Artboard 마지막 아트보드 Gradient Fill: click to apply, drag onto art 그레이디언트 칠: 클릭하여 적용, 아트웍에 끌어 놓기 Gradient Mesh 그레이디언트 메시 Gradient Tool 그레이디언트 도구 @@ -4322,6 +4327,7 @@ effect Vertical 세로 Reset all 모두 재설정 Reset to default 기본값으로 재설정 Reset all Revolve settings to their defaults. 모든 회전체 설정을 기본값으로 재설정합니다. + Step up or down 한 단계 올리기 또는 내리기 Drag values or labels. Double-click a value to type. 값이나 레이블을 드래그하세요. 값을 더블클릭하면 입력할 수 있습니다. Drag rings to rotate. Shift snaps to 15°. 링을 드래그해 회전하세요. Shift를 누르면 15°씩 스냅됩니다. Drag the light, or its rings. Shift snaps to 15°. 조명이나 링을 드래그하세요. Shift를 누르면 15°씩 스냅됩니다. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ba1a49344..42801725d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -747,6 +747,7 @@ Reset all Resetuj wszystko Reset to default Przywróć domyślne Reset all Revolve settings to their defaults. Przywróć domyślne wartości wszystkich ustawień obrotu. + Step up or down Zwiększ lub zmniejsz o krok Drag values or labels. Double-click a value to type. Przeciągaj wartości lub etykiety. Kliknij dwukrotnie wartość, aby ją wpisać. Drag rings to rotate. Shift snaps to 15°. Przeciągaj pierścienie, aby obracać. Shift przyciąga co 15°. Drag the light, or its rings. Shift snaps to 15°. Przeciągaj światło lub jego pierścienie. Shift przyciąga co 15°. @@ -792,6 +793,10 @@ lighting Light Światło Export Keyboard Shortcuts… Eksportuj skróty klawiaturowe… File Info Dialog Okno informacji o pliku Go to Artboard Przejdź do obszaru kompozycji + First Artboard Pierwszy obszar kompozycji + Previous Artboard Poprzedni obszar kompozycji + Next Artboard Następny obszar kompozycji + Last Artboard Ostatni obszar kompozycji Graphic Style Options… Opcje stylu graficznego… Symbol Options Opcje symbolu Symbol Options… Opcje symbolu… @@ -1843,6 +1848,7 @@ lighting Light Światło Variation: Odchylenie: Color Models Modele kolorów Color Picker Próbnik kolorów + Color wheel Koło kolorów Color Swatches Próbki kolorów Only Web Colors Tylko kolory WWW Original: click to restore Oryginał: kliknij, aby przywrócić diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32bf7d118..ea126556b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -463,6 +463,7 @@ Color Model: Modelo de Cor: Color Models Modelos de Cor Color Picker Seletor de Cores + Color wheel Roda de cores Color Picker… Seletor de Cores… Color Reduction: Redução de Cores: Color Settings Configurações de Cor @@ -1055,6 +1056,10 @@ Glyphs Glifos Go To Link Ir para o Link Go to Artboard Ir para Mesa de Trabalho + First Artboard Primeira mesa de trabalho + Previous Artboard Mesa de trabalho anterior + Next Artboard Próxima mesa de trabalho + Last Artboard Última mesa de trabalho Gradient Gradiente Gradient Fill: click to apply, drag onto art Preenchimento de Gradiente: clique para aplicar, arraste sobre a arte Gradient Finishes Finalizações de Gradiente @@ -2923,6 +2928,7 @@ imageTrace Outline Contorno Reset all Redefinir tudo Reset to default Restaurar padrão Reset all Revolve settings to their defaults. Restaurar os valores padrão de todas as configurações de revolução. + Step up or down Aumentar ou diminuir um passo Drag values or labels. Double-click a value to type. Arraste os valores ou rótulos. Clique duas vezes em um valor para digitá-lo. Drag rings to rotate. Shift snaps to 15°. Arraste os anéis para girar. Shift ajusta em incrementos de 15°. Drag the light, or its rings. Shift snaps to 15°. Arraste a luz ou seus anéis. Shift ajusta em incrementos de 15°. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8d1287235..c9c9a4055 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -505,6 +505,7 @@ Color Model: Цветовая модель: Color Models Цветовые модели Color Picker Палитра цветов + Color wheel Цветовой круг Color Picker… Палитра цветов… Color Reduction: Сокращение числа цветов: Color Settings Настройки цвета @@ -1197,6 +1198,10 @@ glyph {id} глиф {id} Glyphs Глифы Go to Artboard Перейти к монтажной области + First Artboard Первая монтажная область + Previous Artboard Предыдущая монтажная область + Next Artboard Следующая монтажная область + Last Artboard Последняя монтажная область Go To Link Перейти по ссылке GPL palettes keep solid colours only, as RGB. Палитры GPL сохраняют только сплошные цвета в RGB. GPU Performance Производительность GPU @@ -4352,6 +4357,7 @@ axis Both Оба Reset all Сбросить всё Reset to default Вернуть значение по умолчанию Reset all Revolve settings to their defaults. Возвращает все настройки вращения профиля к значениям по умолчанию. + Step up or down Шаг вверх или вниз Drag values or labels. Double-click a value to type. Перетаскивайте значения или подписи. Для ввода дважды щёлкните значение. Drag rings to rotate. Shift snaps to 15°. Перетаскивайте кольца для вращения. Shift задаёт шаг 15°. Drag the light, or its rings. Shift snaps to 15°. Перетаскивайте источник света или кольца. Shift задаёт шаг 15°. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 713a977d0..f182c945a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -494,6 +494,7 @@ Color Model: Färgmodell: Color Models Färgmodeller Color Picker Färgväljare + Color wheel Färghjul Color Picker… Färgväljare… Color Reduction: Färgreducering: Color Settings Färginställningar @@ -1177,6 +1178,10 @@ glyph {id} glyf {id} Glyphs Glyfer Go to Artboard Gå till rityta + First Artboard Första ritytan + Previous Artboard Föregående rityta + Next Artboard Nästa rityta + Last Artboard Sista ritytan Go To Link Gå till länk GPL palettes keep solid colours only, as RGB. GPL-paletter sparar bara enfärgade färger, som RGB. GPU Performance GPU-prestanda @@ -4283,6 +4288,7 @@ effect Soft Mjuk Reset all Återställ alla Reset to default Återställ till standard Reset all Revolve settings to their defaults. Återställ alla inställningar för Rotera runt axel till standardvärdena. + Step up or down Ett steg upp eller ned Drag values or labels. Double-click a value to type. Dra i värden eller etiketter. Dubbelklicka på ett värde för att skriva. Drag rings to rotate. Shift snaps to 15°. Dra i ringarna för att rotera. Shift fäster i steg om 15°. Drag the light, or its rings. Shift snaps to 15°. Dra i ljuset eller dess ringar. Shift fäster i steg om 15°. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5d64483ec..a5299733a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -501,6 +501,7 @@ Color Model: Колірна модель: Color Models Колірні моделі Color Picker Палітра кольорів + Color wheel Колірне коло Color Picker… Палітра кольорів… Color Reduction: Скорочення кількості кольорів: Color Settings Налаштування кольору @@ -1193,6 +1194,10 @@ glyph {id} гліф {id} Glyphs Гліфи Go to Artboard Перейти до монтажної області + First Artboard Перша монтажна область + Previous Artboard Попередня монтажна область + Next Artboard Наступна монтажна область + Last Artboard Остання монтажна область Go To Link Перейти за посиланням GPL palettes keep solid colours only, as RGB. Палітри GPL зберігають лише суцільні кольори RGB. GPU Performance Продуктивність GPU @@ -4336,6 +4341,7 @@ axis Both Обидві Reset all Скинути все Reset to default Відновити типове значення Reset all Revolve settings to their defaults. Відновити типові значення всіх параметрів обертання. + Step up or down Крок угору або вниз Drag values or labels. Double-click a value to type. Перетягуйте значення або підписи. Двічі клацніть значення, щоб увести його. Drag rings to rotate. Shift snaps to 15°. Перетягуйте кільця для обертання. Shift задає крок 15°. Drag the light, or its rings. Shift snaps to 15°. Перетягуйте джерело світла або його кільця. Shift задає крок 15°. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dd552e948..149cb4702 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -592,6 +592,7 @@ Color Model: Mô hình màu: Color Models Các mô hình màu Color Picker Bộ chọn màu + Color wheel Bánh xe màu Color Picker… Bộ chọn màu… Color Reduction: Giảm màu: Color Settings Cài đặt màu @@ -960,6 +961,7 @@ Drag to move Kéo để di chuyển Drag to move out of the set Kéo để tách khỏi bộ Drag to the window's left edge to dock Kéo đến cạnh trái cửa sổ để neo + Step up or down Tăng hoặc giảm một bước Drag values or labels. Double-click a value to type. Kéo giá trị hoặc nhãn. Bấm đúp vào giá trị để nhập. Draw Vẽ Draw Behind Vẽ phía sau @@ -1377,6 +1379,10 @@ glyph {id} Glyph {id} Glyphs Glyph Go to Artboard Đến bảng vẽ + First Artboard Bảng vẽ đầu tiên + Previous Artboard Bảng vẽ trước + Next Artboard Bảng vẽ tiếp theo + Last Artboard Bảng vẽ cuối cùng Go To Link Đến liên kết Good Tốt GPL palettes keep solid colours only, as RGB. Bảng màu GPL chỉ lưu màu đặc theo RGB. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 251768913..c1e8299d3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -462,6 +462,7 @@ Color Model: 颜色模型: Color Models 颜色模型 Color Picker 拾色器 + Color wheel 色轮 Color Picker… 颜色选择器… Color Reduction: 色彩缩减: Color Settings 颜色设置 @@ -1146,6 +1147,10 @@ glyph {id} 字符 {id} Glyphs 字形 Go to Artboard 前往画板 + First Artboard 第一个画板 + Previous Artboard 上一个画板 + Next Artboard 下一个画板 + Last Artboard 最后一个画板 Go To Link 转到链接 GPL palettes keep solid colours only, as RGB. GPL 调板仅保留为 RGB 的纯色。 GPU Performance GPU 性能 @@ -3930,6 +3935,7 @@ effect Vertical 垂直 Reset all 全部重置 Reset to default 重置为默认值 Reset all Revolve settings to their defaults. 将所有旋转成形设置重置为默认值。 + Step up or down 逐级增减 Drag values or labels. Double-click a value to type. 拖动数值或标签。双击数值即可输入。 Drag rings to rotate. Shift snaps to 15°. 拖动圆环旋转。按住 Shift 以 15° 为步长调整。 Drag the light, or its rings. Shift snaps to 15°. 拖动光源或圆环。按住 Shift 以 15° 为步长调整。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 23bdde637..696f6fc26 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -476,6 +476,7 @@ Color Model: 色彩模型: Color Models 色彩模型 Color Picker 顏色選擇器 + Color wheel 色輪 Color Picker… 顏色選擇器… Color Reduction: 色彩縮減: Color Settings 色彩設定 @@ -1170,6 +1171,10 @@ glyph {id} 字符 {id} Glyphs 字符 Go to Artboard 前往工作區域 + First Artboard 第一個工作區域 + Previous Artboard 上一個工作區域 + Next Artboard 下一個工作區域 + Last Artboard 最後一個工作區域 Go To Link 前往連結 GPL palettes keep solid colours only, as RGB. GPL 調色盤僅保留純色,並以 RGB 儲存。 GPU Performance GPU 效能 @@ -3714,6 +3719,7 @@ effect Soft 柔和 Reset all 全部重設 Reset to default 重設為預設值 Reset all Revolve settings to their defaults. 將所有旋轉成形設定重設為預設值。 + Step up or down 逐級增減 Drag values or labels. Double-click a value to type. 拖曳數值或標籤。按兩下數值即可輸入。 Drag rings to rotate. Shift snaps to 15°. 拖曳圓環旋轉。按住 Shift 以 15° 為間距調整。 Drag the light, or its rings. Shift snaps to 15°. 拖曳光源或圓環。按住 Shift 以 15° 為間距調整。 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 2902fbad9..c675bce54 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -250,9 +250,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { + use egui::accesskit::{Role, Toggled}; + use super::super::tests_appearance::{click, frame_events, text_rect}; use super::*; use crate::toolbar::tests::widget_rects; + use crate::widgets::accessibility_tests::{role_and_state, tree}; #[test] fn single_object_aligns_to_artboard() { @@ -466,6 +469,20 @@ mod tests { assert_eq!(align_to(&app), AlignTo::Selection, "the stored choice"); } + /// Screen readers name the align buttons, with no pressed state, and the Align To buttons, the + /// chosen one pressed. + #[test] + fn screen_readers_name_the_buttons_and_the_align_to_choice() { + let (mut app, _, _) = back_and_front(); + let tree = tree(|ui| show(&mut app, ui)); + for (_, label, _, _) in ALIGN.iter().chain(DISTRIBUTE.iter()) { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } + assert_eq!(role_and_state(&tree, "Align to Selection"), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Align to Key Object"), (Role::Button, None)); + assert_eq!(role_and_state(&tree, "Align to Artboard"), (Role::Button, None)); + } + /// #789: the Control bar's and the Properties panel's align buttons follow Align To too. #[test] fn the_other_align_buttons_align_to_the_align_to_choice() { diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index a6b5a9d62..2d3d94b2b 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -761,7 +761,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } match v { Value::Bool(b) => { - if widgets::check(ui, "", *b, true) { + if widgets::check_box(ui, label, *b, true) { change = Some((key.clone(), json!(!b))); } } diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 58b915f4b..61349e934 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -498,7 +498,7 @@ fn tint_rows(ui: &mut Ui, tint: &Tint, slider_w: f32, field_w: f32) -> Option<(f ui.horizontal(|ui| { ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("T").size(12.5).color(t.text))); let track = |x: f32| to32(&tint.base.tinted(x)); - let (nv, phase) = widgets::color_slider(ui, ("color-slider", "tint"), tint.tint, slider_w, &track); + let (nv, phase) = widgets::color_slider_named(ui, ("color-slider", "tint"), "T", tint.tint, slider_w, &track); let field = widgets::plain_field(ui, ("color-field", "tint"), (tint.tint * 100.0).round() as f64, "%", 0, field_w); match (nv, field) { (Some(v), _) => Some(((v * 100.0).round() / 100.0, phase)), @@ -613,9 +613,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let track = |x: f32| to32(&track_color(mode, &comps, i, x)); let grey = |_x: f32| t.input; let (nv, phase) = if enabled { - widgets::color_slider(ui, ("color-slider", i), mode.unit(i, v), slider_w, &track) + widgets::color_slider_named(ui, ("color-slider", i), lbl, mode.unit(i, v), slider_w, &track) } else { - widgets::color_slider(ui, ("color-slider", i), 0.0, slider_w, &grey) + widgets::color_slider_named(ui, ("color-slider", i), lbl, 0.0, slider_w, &grey) }; if let Some(nv) = nv && enabled diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index c9c8a478c..8de5ff1eb 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -216,7 +216,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { None => tl!("Limit colors to swatch library").into(), }; // The tip is translated around the library's name, not looked up whole. - let lr = widgets::icon_button(ui, "library", "", limit.is_some(), 24.0).on_hover_text(tip); + let lr = widgets::icon_button(ui, "library", "", limit.is_some(), 24.0); + lr.widget_info(|| widgets::icon_button_info(ui.is_enabled(), limit.is_some(), &tip)); + let lr = lr.on_hover_text(tip); egui::Popup::menu(&lr).show(|ui| limit_menu(app, ui)); ui.add_space((ui.available_width() - 3.0 * 28.0).max(0.0)); // Recolor Artwork with the harmony colours as the new colours: the selected art's, or @@ -278,6 +280,20 @@ mod tests { out.textures_delta.clear(); } + /// Screen readers name the library button by its tool tip, pressed while a library limits the + /// colors. + #[test] + fn screen_readers_name_the_limit_button() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + use egui::accesskit::{Role, Toggled}; + let mut app = app(); + let shown = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&shown, "Limit colors to swatch library"), (Role::Button, None)); + app.run("ui.colorGuideLimit", json!({"library": "Earth Tones"})).unwrap(); + let shown = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&shown, "Limit colors to swatch library: Earth Tones"), (Role::Button, Some(Toggled::True))); + } + #[test] fn base_stays_until_set_and_the_grid_follows_the_options() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/color_themes.rs b/crates/ui-egui/src/panels/color_themes.rs index 751f8dfd8..acc2fa8bb 100644 --- a/crates/ui-egui/src/panels/color_themes.rs +++ b/crates/ui-egui/src/panels/color_themes.rs @@ -212,7 +212,9 @@ fn create(app: &mut VectorcraftApp, ui: &mut Ui) { None => tl!("Save theme to My Themes").into(), }; // The tip is translated around the theme's name, not looked up whole. - if widgets::icon_button(ui, "save", "", false, 24.0).on_hover_text(tip).clicked() { + let resp = widgets::icon_button(ui, "save", "", false, 24.0); + resp.widget_info(|| widgets::icon_button_info(ui.is_enabled(), false, &tip)); + if resp.on_hover_text(tip).clicked() { save(app, &ctx); } let doc = app.session.active().is_some(); @@ -368,6 +370,15 @@ mod tests { &app.session.prefs.color_themes } + /// Screen readers name the Save button by its tool tip. + #[test] + fn screen_readers_name_the_save_button() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + let mut app = app(); + let shown = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&shown, "Save theme to My Themes"), (egui::accesskit::Role::Button, None)); + } + #[test] fn create_save_edit_and_resave_a_theme() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 32e667881..ae02d1cbf 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -174,11 +174,10 @@ pub(crate) const TRACE_BUTTON_W: f32 = 104.0; /// The Image Trace button for a selected image (Control bar, Properties): a click traces it with /// the Default preset, its arrow lists the presets to trace it with. pub(crate) fn trace_button(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { - let (main, arrow) = widgets::split_button(ui, tl!("Image Trace"), width); + let (main, arrow) = widgets::split_button_named(ui, tl!("Image Trace"), tl!("Tracing Presets"), width); if main.on_hover_text(tl!("Trace the image with the Default preset")).clicked() { trace_with(app, "Default"); } - let arrow = arrow.on_hover_text(tl!("Tracing Presets")); if let Some(p) = egui::Popup::menu(&arrow).show(|ui| preset_list(app, ui, "")).and_then(|r| r.inner) { trace_with(app, &p); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index e76596486..984b8385c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -385,13 +385,13 @@ pub(crate) fn paint_chip(app: &mut VectorcraftApp, ui: &mut Ui, stroke: bool, si let (fill, stroke_paint) = current_paints(app); let mixed = mixed_paints(app, ui.ctx()); let (paint, mixed) = if stroke { (stroke_paint, mixed.1) } else { (fill, mixed.0) }; - let resp = crate::widgets::chip_button(ui, size, chevron, |ui, chip| { - crate::widgets::proxy_chip(ui, chip, &paint, mixed, stroke.then_some(size * 0.25)); - }); - let resp = resp.on_hover_text(if stroke { - "Stroke: click for swatches, Shift-click for the color mixer" + let tip = if stroke { + tl!("Stroke: click for swatches, Shift-click for the color mixer") } else { - "Fill: click for swatches, Shift-click for the color mixer" + tl!("Fill: click for swatches, Shift-click for the color mixer") + }; + let resp = crate::widgets::chip_button_named(ui, size, chevron, tip, |ui, chip| { + crate::widgets::proxy_chip(ui, chip, &paint, mixed, stroke.then_some(size * 0.25)); }); if resp.clicked() { app.run("paint.toggleActive", json!({ "fill": !stroke })).ok(); diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index 4af13e7a4..fa56be3cf 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -263,7 +263,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } let sw = (ui.available_width() - 30.0).max(40.0); let track = |_: f32| t.input_border; - if let (Some(v), phase) = widgets::color_slider(ui, "nav-slider", zoom_to_slider(zoom) as f32, sw, &track) + if let (Some(v), phase) = widgets::color_slider_named(ui, "nav-slider", tl!("Zoom"), zoom_to_slider(zoom) as f32, sw, &track) && phase != widgets::Live::Idle { z = Some(slider_to_zoom(v as f64)); diff --git a/crates/ui-egui/src/panels/pathfinder.rs b/crates/ui-egui/src/panels/pathfinder.rs index 425772367..440b4053b 100644 --- a/crates/ui-egui/src/panels/pathfinder.rs +++ b/crates/ui-egui/src/panels/pathfinder.rs @@ -100,6 +100,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] mod tests { use super::*; + use crate::widgets::accessibility_tests::{named, tree}; use vectorcraft_engine::doc::{NodeId, NodeKind}; /// #1635: a stroked object expanded inside a group leaves a group holding one group (its fill @@ -125,4 +126,20 @@ mod tests { let b = united.geometric_bounds().unwrap(); assert_eq!((b.x0, b.y0, b.x1, b.y1), (5.0, 5.0, 65.0, 65.0), "the stroke's outer edge"); } + + /// Screen readers name each Shape Mode and Pathfinder button, enabled for two selected objects. + #[test] + fn screen_readers_name_the_buttons() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + let b = app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let tree = tree(|ui| show(&mut app, ui)); + for (_, label, _) in SHAPE_MODES.into_iter().chain(PATHFINDERS) { + let node = named(&tree, label); + assert_eq!(node.role(), egui::accesskit::Role::Button, "{label}"); + assert!(!node.is_disabled(), "{label}"); + } + } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e46b86498..4c9235cb1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -161,6 +161,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option widgets::gradient_chip(ui, r, &app.session.last_gradient), _ => widgets::paint_chip(ui, r, &Paint::None), } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(tip))); if resp.on_hover_text(tl!(tip)).clicked() { clicked = Some(cmd); } @@ -600,6 +604,7 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE)); let xr = ui.interact(x, egui::Id::new(("tool-flyout-close", key)), Sense::click()); icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text }); + xr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Put back in the toolbar"))); out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked(); egui::Rect::from_min_max(bar.min + vec2(0.0, CLOSE), bar.max) } else { @@ -611,11 +616,12 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating let x = grip.center().x - 2.0 + k as f32 * 2.0; ui.painter().line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], Stroke::new(0.6, t.text_disabled)); } + let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") }; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip)); let resp = if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); resp } else { - let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") }; resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip) }; out.bar = Some(resp); @@ -647,6 +653,7 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< if let Some(sc) = shortcut { ui.painter().galley(pos2(r.right() - ROW_END - sc.size().x, r.center().y - sc.size().y / 2.0), sc, color); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), is_active, tip(tool))); if resp.clicked() { chosen = Some(tool.id); } @@ -700,6 +707,7 @@ fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option } let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0)); icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active == tool.id, tip(tool))); if resp.on_hover_text(tip(tool)).clicked() { chosen = Some(tool.id); } @@ -840,10 +848,12 @@ fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { #[cfg(test)] pub(crate) mod tests { + use egui::accesskit::{Role, Toggled}; use egui::{Event, PointerButton, Pos2}; use vectorcraft_engine::Session; use super::*; + use crate::widgets::accessibility_tests::{role_and_state, tree}; /// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { @@ -929,6 +939,22 @@ pub(crate) mod tests { } } + /// Screen readers name each tool button by its tool tip and say which tool is active, and name + /// the title bar's column toggle and grip and the paint buttons under the tools. + #[test] + fn screen_readers_name_the_tool_buttons_and_the_active_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.select_tool("pen"); + let tree = tree(|ui| show(&mut app, ui)); + let name = |id| tool_info(id).map(tip).unwrap_or_default(); + assert_eq!(role_and_state(&tree, &name("pen")), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, &name("selection")), (Role::Button, Some(Toggled::False))); + for label in ["Toggle single/double column", "Drag to float the toolbar", "Color (,)", "Gradient (.)", "None (/)"] { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } + } + /// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a /// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel. #[test] @@ -1564,4 +1590,31 @@ pub(crate) mod tests { app.session.prefs.show_tool_tips = true; assert!(hovered_texts(&mut app, &ctx, 30.0, star).contains(&tip), "on again"); } + + /// Screen readers name a flyout's rows and a floating strip's buttons by their tools' tool tips, + /// with the active tool's pressed, and name the strip's close box and both tear-off bars. + #[test] + fn screen_readers_name_a_flyouts_rows_and_a_floating_strips_buttons() { + let name = |id| tool_info(id).map(tip).unwrap_or_default(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let group: Vec = ["rectangle", "roundedRectangle", "ellipse"].map(String::from).to_vec(); + let flyout = tree(|ui| { + app.ui.flyout = Some(0); + ui.data_mut(|d| d.insert_temp(egui::Id::new("flyout-tools"), group.clone())); + show(&mut app, ui); + }); + assert_eq!(role_and_state(&flyout, &name("roundedRectangle")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&flyout, &name("ellipse")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&flyout, "Drag to float as a toolbar"), (Role::Button, None)); + app.ui.flyout = None; + app.select_tool("star"); + let tools: Vec = ["polygon", "star"].map(String::from).to_vec(); + app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] }); + let strip = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&strip, &name("polygon")), (Role::Button, Some(Toggled::False))); + assert_eq!(role_and_state(&strip, &name("star")), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&strip, "Put back in the toolbar"), (Role::Button, None)); + assert_eq!(role_and_state(&strip, "Drag to move"), (Role::Button, None)); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3d0096cd5..0dd7347e8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -28,6 +28,7 @@ pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, sel ui.painter().rect_filled(rect, CornerRadius::same(5), bg); let pad = (size * 0.2).round(); icons::paint(ui, icon, rect.shrink(pad), if selected { t.text_strong } else { t.icon }); + resp.widget_info(|| icon_button_info(ui.is_enabled(), selected, tl!(tip))); if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp } } @@ -39,6 +40,7 @@ pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); flat_fill(ui, rect, &resp); ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp } @@ -48,6 +50,12 @@ pub const SPLIT_ARROW: f32 = 18.0; /// A [`flat_button`] `width` wide whose right end is a menu arrow (Image Trace ▾) → the /// responses of the button and of its arrow. pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) { + split_button_named(ui, text, "", width) +} + +/// [`split_button`] whose arrow has the tool tip `arrow_tip`, which screen readers also call it +/// (none when empty). +pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) -> (Response, Response) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); let rect = rect.shrink2(vec2(0.0, 0.5)); @@ -68,7 +76,12 @@ pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) t.text_strong, ); icons::paint(ui, "chevron-down", Rect::from_center_size(arrow_rect.center(), vec2(12.0, 12.0)), t.text_dim); - (main, arrow) + main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); + if arrow_tip.is_empty() { + return (main, arrow); + } + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(arrow_tip))); + (main, arrow.on_hover_text(tl!(arrow_tip))) } /// The pressed or hovered fill of a flat button part. @@ -103,6 +116,7 @@ pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Respons ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); } ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), tl!(text))); resp } @@ -114,6 +128,7 @@ pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click()); ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button }); ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp } @@ -657,6 +672,11 @@ pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option< /// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with /// `chevron` a chevron follows it. pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response { + chip_button_named(ui, size, chevron, "", draw) +} + +/// [`chip_button`] with the tool tip `tip`, which screen readers also call it (none when empty). +pub fn chip_button_named(ui: &mut Ui, size: f32, chevron: bool, tip: &str, draw: impl FnOnce(&Ui, Rect)) -> Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click()); let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0); @@ -665,7 +685,11 @@ pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, if chevron { icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim); } - resp + if tip.is_empty() { + return resp; + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(tip))); + resp.on_hover_text(tl!(tip)) } /// What the user did on a [`fill_stroke_proxy`]. @@ -732,6 +756,13 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool } else { None }; + let on = ui.is_enabled(); + fill_resp + .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); + stroke_resp + .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); + swap.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); + def.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!("Default Fill and Stroke (D)"))); ProxyClicks { fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(), stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(), @@ -834,6 +865,12 @@ fn searchable_combo(ui: &mut Ui, id: egui::Id, current: &str, width: f32, lis ); let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y); let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + // Screen readers get what egui's combo box gives them: its role and the text it shows. + resp.widget_info(|| egui::WidgetInfo { + enabled: ui.is_enabled(), + current_text_value: Some(current.to_owned()), + ..egui::WidgetInfo::new(egui::WidgetType::ComboBox) + }); if ui.is_rect_visible(rect) { let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); @@ -1027,6 +1064,7 @@ pub fn opacity_blend( let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), tl!("Opacity"))); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let mut o = opacity.unwrap_or(1.0) * 100.0; let r = slider(ui, egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); @@ -1057,9 +1095,22 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { let line = Stroke::new(1.0, t.text_dim); ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle); let mut out = None; - for i in 0..9 { + // Screen readers name the squares left to right and top to bottom. + let names = [ + tl!("Top Left"), + tl!("Top"), + tl!("Top Right"), + tl!("Left"), + tl!("Center"), + tl!("Right"), + tl!("Bottom Left"), + tl!("Bottom"), + tl!("Bottom Right"), + ]; + for (i, name) in names.into_iter().enumerate() { let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); let resp = ui.interact(r.expand(2.0), ui.id().with(("refpt", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), i == current, name)); if resp.clicked() { out = Some(i); } @@ -1086,6 +1137,7 @@ pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, angle let a = (angle as f32).to_radians(); ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * (r - 3.0)], Stroke::new(1.5, t.text)); ui.painter().circle_filled(c, 2.0, t.text); + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), angle, tl!("Angle"))); let (p, _) = pointer_phase(&resp)?; let v = p - c; if v.length() < 2.0 { @@ -1107,6 +1159,7 @@ pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) - } else if resp.hovered() { icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, tl!(tip))); resp.on_hover_text(tl!(tip)).clicked() } @@ -1121,9 +1174,16 @@ pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, e let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); let pad = (size * 0.2).round(); icons::paint(ui, icon, rect.shrink(pad), t.text_disabled); + resp.widget_info(|| icon_button_info(false, selected, tl!(tip))); if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) } } +/// What screen readers get for an icon button: a button called `name` (already translated), +/// reported as pressed while `selected` and with no pressed state otherwise. +pub(crate) fn icon_button_info(enabled: bool, selected: bool, name: &str) -> egui::WidgetInfo { + egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name) } +} + /// A slider whose rail shows, filled up to the handle: the theme's widget fill matches the panel /// and dialog background, which leaves only the handle visible (#1483). pub fn slider(ui: &mut Ui, slider: egui::Slider<'_>) -> Response { @@ -1157,6 +1217,7 @@ pub fn link_label(ui: &mut Ui, text: &str) -> Response { ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); x += 2.5; } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp } @@ -1202,9 +1263,20 @@ pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool { check_row(ui, name, Some(value), true).0 } +/// A [`check`] box without text, for a row whose label is drawn beside it in another column: +/// screen readers call it `name` (already translated). Returns true when toggled. +pub fn check_box(ui: &mut Ui, name: &str, value: bool, enabled: bool) -> bool { + check_row_named(ui, "", name, Some(value), enabled).0 +} + /// [`check3`] with its `text` already translated, also returning the row's response (what a /// tooltip goes on: a widget around it never counts as hovered under the box). fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bool, Response) { + check_row_named(ui, text, text, value, enabled) +} + +/// [`check_row`] that screen readers call `name` instead of its `text`. +fn check_row_named(ui: &mut Ui, text: &str, name: &str, value: Option, enabled: bool) -> (bool, Response) { let t = Tokens::get(ui.ctx()); let (bx, resp, border) = choice_row(ui, text, enabled); let checked = value == Some(true); @@ -1222,18 +1294,34 @@ fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bo } Some(false) => {} } + resp.widget_info(|| match value { + Some(on) => egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled && ui.is_enabled(), on, name), + None => egui::WidgetInfo::labeled(egui::WidgetType::Checkbox, enabled && ui.is_enabled(), name), + }); (enabled && resp.clicked(), resp) } /// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, tl!(label), tl!(label), selected, enabled) +} + +/// A [`radio`] button without text, for one followed by a field and the text that names it: +/// screen readers call it `name` (already translated). Returns true when clicked. +pub fn radio_button(ui: &mut Ui, name: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, "", name, selected, enabled) +} + +/// [`radio`] with its `text` already translated, which screen readers call `name`. +fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); - let (bx, resp, border) = choice_row(ui, tl!(label), enabled); + let (bx, resp, border) = choice_row(ui, text, enabled); let c = bx.center(); ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border)); if selected { ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, enabled && ui.is_enabled(), selected, name)); enabled && resp.clicked() } @@ -1348,6 +1436,7 @@ fn spin_generic( ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tl!("Step up or down"))); // A blank field (values that differ) has nothing to step from. if sresp.clicked() && let Some(p) = sresp.interact_pointer_pos() @@ -1405,6 +1494,19 @@ pub fn color_slider( value: f32, width: f32, track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + color_slider_named(ui, id, "", value, width, track) +} + +/// [`color_slider`] that screen readers get as a slider named `name` (already translated) with no +/// value (none when `name` is empty). +pub fn color_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + name: &str, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, ) -> (Option, Live) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); @@ -1418,6 +1520,9 @@ pub fn color_slider( let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); + if !name.is_empty() { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name)); + } match pointer_phase(&resp) { Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), None => (None, Live::Idle), @@ -1506,6 +1611,19 @@ pub fn channel_slider( value: f32, size: Vec2, track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + channel_slider_named(ui, id, "", value, size, track) +} + +/// [`channel_slider`] that screen readers get as a slider named `name` (already translated) with +/// no value, as for [`color_slider_named`] (none when `name` is empty). +pub fn channel_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug, + name: &str, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, ) -> (Option, Live) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); @@ -1519,6 +1637,9 @@ pub fn channel_slider( let tip = pos2(x + d * 5.0, y); ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); } + if !name.is_empty() { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name)); + } match pointer_phase(&resp) { Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), None => (None, Live::Idle), @@ -1881,13 +2002,15 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); out.moved = Some((i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]])); } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), tl!("Color wheel"))); }); ui.add_space(6.0); ui.horizontal(|ui| { ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0)); ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim))); if let (Some(b), Some([h, s, _])) = (base, base_hsb) - && let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x))) + && let (Some(nv), _) = + color_slider_named(ui, (id, "brightness"), tl!("Brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x))) { out.moved = Some((b, [h, s, nv])); } @@ -1916,6 +2039,7 @@ pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { t.accent, ); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), sel, tl!(tab))); if resp.clicked() { clicked = Some(i); } @@ -1942,6 +2066,7 @@ pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &st ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, tl!(tip))); resp.on_hover_text(tl!(tip)).clicked() } @@ -1987,6 +2112,7 @@ pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) ui.painter().add(egui::Shape::line(path, Stroke::new(1.2, color))); ui.painter().add(egui::Shape::convex_polygon(head, color, Stroke::NONE)); } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip)); resp.on_hover_text(tip).clicked() } @@ -2020,6 +2146,7 @@ fn chevron_cell(ui: &mut Ui, h: f32) -> Response { t.icon }; icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Presets"))); dresp } @@ -2044,6 +2171,159 @@ pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, val out } +#[cfg(test)] +pub(crate) mod accessibility_tests { + use egui::accesskit::{Node, Role, Toggled}; + + use super::*; + + /// The screen-reader tree of the second of two frames of `add` in a 1280 × 900 window, in + /// English. + pub(crate) fn tree(mut add: impl FnMut(&mut Ui)) -> Vec { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0)); + let mut nodes = vec![]; + for time in [0.0, 0.1] { + let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, &mut add); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + } + nodes + } + + /// The node of `tree` that screen readers name `label`. + pub(crate) fn named<'a>(tree: &'a [Node], label: &str) -> &'a Node { + tree.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("nothing is named {label:?}")) + } + + /// The role of the node named `label`, and whether it is pressed, checked or chosen. + pub(crate) fn role_and_state(tree: &[Node], label: &str) -> (Role, Option) { + let n = named(tree, label); + (n.role(), n.toggled()) + } + + #[test] + fn buttons_are_named_by_their_text_or_tip() { + let tree = tree(|ui| { + icon_button(ui, "x", "Remove", false, 24.0); + flat_button(ui, "Make", 64.0); + split_button_named(ui, "Image Trace", "Tracing Presets", 104.0); + primary_button(ui, "OK"); + primary_button_enabled(ui, "Create", false); + secondary_button(ui, "Cancel"); + link_label(ui, "Stroke:"); + icon_button_enabled(ui, "trash-2", "Delete Brush", false, false, 24.0); + fill_stroke_proxy(ui, &Paint::None, &Paint::None, (false, false), true, 36.0); + chip_button_named(ui, 22.0, true, "Graphic Style", |_, _| {}); + spin_field(ui, "weight", Some(1.0), Unit::Points, 80.0, 1.0, 0.0, &[1.0, 2.0]); + opacity_blend(ui, "opacity", Some(1.0), Some(BlendMode::Normal), true); + }); + for label in [ + "Remove", + "Make", + "Image Trace", + "Tracing Presets", + "OK", + "Create", + "Cancel", + "Stroke:", + "Delete Brush", + "Swap Fill and Stroke (Shift+X)", + "Graphic Style", + "Step up or down", + "Presets", + "Opacity", + ] { + assert_eq!(role_and_state(&tree, label), (Role::Button, None), "{label}"); + } + assert_eq!(role_and_state(&tree, "Default Fill and Stroke (D)"), (Role::Button, None)); + assert_eq!(role_and_state(&tree, "Fill (X), double-click for the Color Picker"), (Role::Button, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Stroke (X), double-click for the Color Picker"), (Role::Button, Some(Toggled::False))); + assert!(named(&tree, "Create").is_disabled(), "a primary button that is not enabled"); + assert!(named(&tree, "Delete Brush").is_disabled(), "an icon button that is not enabled"); + assert!(!named(&tree, "OK").is_disabled()); + } + + #[test] + fn toggles_say_whether_they_are_on() { + let tree = tree(|ui| { + icon_button(ui, "link", "Constrain Proportions", true, 24.0); + toggle_icon(ui, "eye", true, 18.0, "Show all plates"); + toggle_icon(ui, "eye", false, 18.0, "Show/hide plate (Alt-click: only this plate)"); + orientation_button(ui, false, true, "Portrait"); + orientation_button(ui, true, false, "Landscape"); + layout_button(ui, &[(0, 0), (1, 0)], true, "Grid by Row"); + tab_bar(ui, &["Recent", "Saved"], 1); + }); + let on = |label| assert_eq!(role_and_state(&tree, label), (Role::Button, Some(Toggled::True)), "{label}"); + let off = |label| assert_eq!(role_and_state(&tree, label), (Role::Button, Some(Toggled::False)), "{label}"); + on("Constrain Proportions"); + on("Show all plates"); + off("Show/hide plate (Alt-click: only this plate)"); + on("Portrait"); + off("Landscape"); + on("Grid by Row"); + off("Recent"); + on("Saved"); + } + + #[test] + fn checkboxes_and_radio_buttons_are_named_and_checked() { + let tree = tree(|ui| { + check(ui, "Preview", true, true); + check(ui, "Snap to Pixel", false, false); + check3(ui, "Overprint Fill", None, true); + check_name(ui, "Artboard 1", true); + check_box(ui, "Cyan", false, true); + radio(ui, "Relative", true, true); + radio(ui, "Absolute", false, true); + radio_button(ui, "per slice", true, true); + reference_point(ui, 4); + }); + assert_eq!(role_and_state(&tree, "Preview"), (Role::CheckBox, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Snap to Pixel"), (Role::CheckBox, Some(Toggled::False))); + assert!(named(&tree, "Snap to Pixel").is_disabled()); + assert_eq!(role_and_state(&tree, "Overprint Fill"), (Role::CheckBox, Some(Toggled::Mixed))); + assert_eq!(role_and_state(&tree, "Artboard 1"), (Role::CheckBox, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Relative"), (Role::RadioButton, Some(Toggled::True))); + assert_eq!(role_and_state(&tree, "Absolute"), (Role::RadioButton, Some(Toggled::False))); + assert_eq!(role_and_state(&tree, "Cyan"), (Role::CheckBox, Some(Toggled::False))); + assert_eq!(role_and_state(&tree, "per slice"), (Role::RadioButton, Some(Toggled::True))); + for (i, label) in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"].into_iter().enumerate() + { + let chosen = if i == 4 { Toggled::True } else { Toggled::False }; + assert_eq!(role_and_state(&tree, label), (Role::RadioButton, Some(chosen)), "{label}"); + } + } + + #[test] + fn sliders_dials_and_combo_boxes_are_named() { + let red = Color::from_hsb(0.0, 1.0, 1.0); + let tree = tree(|ui| { + color_slider_named(ui, "fidelity", "Fidelity:", 0.25, 120.0, &|_| Color32::GRAY); + angle_dial(ui, "angle", 45.0, 28.0); + channel_slider_named(ui, "channel", "H", 0.5, vec2(22.0, 80.0), &|_| Color32::GRAY); + harmony_wheel(ui, "wheel", 120.0, &[red], Some(0)); + combo(ui, "font", "Regular", 120.0, true, |_| None::); + }); + let slider = |label| { + let n = named(&tree, label); + (n.role(), n.numeric_value()) + }; + assert_eq!(slider("Fidelity:"), (Role::Slider, None)); + assert_eq!(slider("Angle"), (Role::Slider, Some(45.0))); + assert_eq!(slider("H"), (Role::Slider, None)); + assert_eq!(slider("Color wheel"), (Role::Slider, None)); + assert_eq!(slider("Brightness"), (Role::Slider, None)); + assert!(tree.iter().any(|n| n.role() == Role::ComboBox && n.value() == Some("Regular")), "the searchable combo box shows its text"); + } +} + #[cfg(test)] mod spinner_modifier_tests { use super::*; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f8f68e48a..de7b0e9c4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,6 +431,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it). - With one image selected, the contextual task bar offers Image Trace, which traces the image at once with the Default preset (the Image Trace panel is left as it was), and Crop Image; with an Image Trace object it offers Expand, which keeps only the traced paths. As in Illustrator ("Selecting images displays options like Image Trace, Retype, and Crop Image"; "You can also quickly expand traced artwork from the bar": [Contextual Task Bar announcement](https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580)). Offset Path and Simplify, which don't apply to an image, aren't offered for one. Not done: Retype, which the app doesn't have. - 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. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels; new variable keys fall back to English in other languages. Evidence: `i18n::tests::czech_render_site_follow_up`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). From 3fc78187ecb0fccdb37d8d398d05f641da083dc1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:21:31 -0700 Subject: [PATCH 068/184] TIFF: planar grayscale files with extra channels, and planar CMYK files, open and place TIFFs that store each channel as its own plane (PlanarConfiguration 2) did not open, place or relink when they were CMYK or grayscale with extra channels: the image decoder refuses planar CMYK, and the readers for CMYK inks and for extra samples returned nothing for planar files. The two readers now read every plane through the tiff crate and put each pixel's samples together. 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. Grayscale TIFFs with an alpha channel or other extra channels, and CMYK TIFFs with alpha, read as their chunky versions do. A TIFF whose header claims more strips or tiles than the tiff decoder can count is refused as too large to draw. The decoder multiplies those counts without a check, which panics in builds with overflow checks. Part of #1466. Docs: docs/file-format-parity.md, docs/target-app-parity.md. --- crates/doc/src/cmyk.rs | 237 ++++++++++++++++++++++---- crates/engine/src/cmd/fileio/load.rs | 28 ++- crates/engine/src/cmd/fileio/tests.rs | 94 ++++++++++ crates/engine/tests/import_fuzz.rs | 13 +- crates/testkit/src/lib.rs | 2 + crates/testkit/src/tiff.rs | 116 +++++++++++++ docs/file-format-parity.md | 1 + docs/target-app-parity.md | 3 +- 8 files changed, 451 insertions(+), 43 deletions(-) create mode 100644 crates/testkit/src/tiff.rs diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e4c73f28a..2ca605db0 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -2,10 +2,12 @@ //! //! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's //! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK -//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the -//! latter, Deflate-compressed. Every other image decodes to RGB(A) only. +//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink). +//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image +//! decodes to RGB(A) only. use std::io::Cursor; +use std::sync::Arc; use zune_jpeg::zune_core::bytestream::ZCursor; use zune_jpeg::zune_core::colorspace::ColorSpace; @@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool { bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") } -/// The colour type of TIFF `bytes` and its decoder. +/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than +/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane +/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2) +/// has a plane for each channel; other files have one. The decoder multiplies these counts without +/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel +/// wide, claims at least 2^32 samples. +/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images +/// and for bytes that aren't a TIFF. +pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> { + let le = bytes.starts_with(b"II*\0"); + if !le && !bytes.starts_with(b"MM\0*") { + return None; + } + let u16_at = |at: usize| -> Option { + let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }) + }; + let u32_at = |at: usize| -> Option { + let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }) + }; + let ifd = usize::try_from(u32_at(4)?).ok()?; + // (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its + // single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points) + let entry = |i: usize| -> Option<(u16, Option)> { + let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?; + let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?); + let value = match (ty, count) { + (1, 1) => bytes.get(at).map(|&b| u32::from(b)), + (3, 1) => u16_at(at).map(u32::from), + (4 | 13, 1) => u32_at(at), + (16 | 18, 1) => { + let at = usize::try_from(u32_at(at)?).ok()?; + let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?); + let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) }; + u32::try_from(wide).ok() + } + _ => None, + }; + Some((u16_at(e)?, value)) + }; + let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false); + let (mut rows, mut tile_w, mut tile_h) = (None::, None::, None::); + let least = |was: Option, v: u32| Some(was.map_or(v, |w| w.min(v))); + for i in 0..usize::from(u16_at(ifd)?) { + let Some((tag, value)) = entry(i) else { break }; + let Some(v) = value else { continue }; + match tag { + 256 => width = width.max(v), + 257 => height = height.max(v), + 277 => planes = planes.max(v), + 278 => rows = least(rows, v), + 284 => planar |= v == 2, + 322 => tile_w = least(tile_w, v), + 323 => tile_h = least(tile_h, v), + _ => {} + } + } + let planes = if planar { u128::from(planes) } else { 1 }; + let tiles = match (tile_w, tile_h) { + (Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128, + _ => false, + }; + let rows = rows.unwrap_or(height); + let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX); + (tiles || strips).then_some((width, height)) +} + +/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too. fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder>)> { - if !is_tiff(bytes) { + if !is_tiff(bytes) || oversized_tiff(bytes).is_some() { return None; } let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?; Some((d.colortype().ok()?, d)) } +/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? +fn planar(d: &mut tiff::decoder::Decoder>) -> bool { + d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) +} + +/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color +/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file +/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to +/// that many samples, which includes images whose planes together need more than the decoder's +/// limit (256 MB of samples). +fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { + let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); + let layout = d.read_image_to_buffer(&mut buf).ok()?; + let data = match buf { + tiff::decoder::DecodingResult::U8(v) => v, + tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + _ => return None, + }; + if Some(data.len()) != n.checked_mul(per) { + return None; + } + if layout.planes <= 1 { + return Some(data); + } + if layout.planes != per { + return None; + } + let planes: Vec<&[u8]> = data.chunks_exact(n).collect(); + Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect()) +} + impl ImageBlob { /// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written. pub fn cmyk_tiff(inks: &Inks) -> Option { - use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; + Self::cmyk_tiff_at(inks, None) + } + + /// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per + /// inch) when it is given and both are finite and above 0. + pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option { + use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; + use tiff::tags::{ResolutionUnit, Tag}; let mut out = Cursor::new(Vec::new()); - TiffEncoder::new(&mut out) - .ok()? - .with_compression(Compression::Deflate(DeflateLevel::Balanced)) - .write_image::(inks.width, inks.height, &inks.data) - .ok()?; + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + let mut img = enc.new_image::(inks.width, inks.height).ok()?; + if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; + } + img.write_data(&inks.data).ok()?; Some(Self::new("image/tiff", out.into_inner())) } @@ -119,34 +232,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option { Inks::new(w, h, data) } -/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits). +/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane +/// per ink. fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - // Planar samples (one plane per ink) would read as interleaved. - let planar = d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); - if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() { + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { return None; } - let data = match d.read_image().ok()? { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; - Inks::new(w, h, data) + let n = usize::try_from(pixels(w, h)?).ok()?; + Inks::new(w, h, read_samples(&mut d, n, 4)?) +} + +/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per +/// ink, which the image decoder doesn't read, their ink amounts written again by +/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK +/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { + let inks = tiff_inks(bytes)?; + let (_, mut d) = tiff(bytes)?; + if !planar(&mut d) { + return Some(bytes.to_vec()); + } + ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// 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. `None` for other -/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. +/// 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`]. 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)?; let (w, h) = d.dimensions().ok()?; - let planar = d.find_tag_unsigned::(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); // 0: white is zero, 1: black is zero (the two grayscales). let photometric = d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok().flatten(); // ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha. @@ -159,18 +280,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) } _ => return None, }; - if planar { - return None; - } let n = usize::try_from(pixels(w, h)?).ok()?; - let data: Vec = match d.read_image().ok()? { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; - if Some(data.len()) != n.checked_mul(samples) { - return None; - } + 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 }; @@ -246,6 +357,64 @@ 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"); } + /// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel, + /// times 2 planes. + #[test] + fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() { + let header = |planar: u32| { + let mut f = b"II*\0\x08\0\0\0".to_vec(); + let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)]; + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + for (tag, v) in entries { + f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat()); + f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat()); + } + f.extend([0; 4]); + f + }; + assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX))); + assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits"); + assert!(tiff(&header(2)).is_none(), "not decoded"); + } + + /// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2 + /// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and + /// reads a LONG8 value from where the entry points. + #[test] + fn tiffs_with_more_strips_than_can_be_counted_are_oversized() { + // Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory). + let header = |entries: &[(u16, u16, u32)]| { + let after = 8 + 2 + 12 * entries.len() + 4; + let mut f = b"II*\0\x08\0\0\0".to_vec(); + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + let mut wide = vec![]; + for &(tag, ty, v) in entries { + f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat()); + f.extend(1u32.to_le_bytes()); + if ty == 16 { + f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes()); + wide.extend(u64::from(v).to_le_bytes()); + } else { + f.extend(v.to_le_bytes()); + } + } + f.extend([0; 4]); + f.extend(wide); + f + }; + let tall = 1u32 << 31 | 1; + let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)]; + assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall))); + let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat(); + assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets"); + let mut long8 = strips; + long8[1] = (257, 16, tall); + assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8"); + for h in [header(&tile_sizes), header(&long8)] { + assert!(tiff(&h).is_none(), "not decoded"); + } + } + fn inks() -> Inks { Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap() } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 3834aa7e1..463517974 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -447,18 +447,28 @@ pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC; /// decode, and for bytes that aren't an image. pub(crate) fn too_large_note(bytes: &[u8]) -> Option { use image::ImageDecoder as _; + if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { + return Some(format!("an image of {w} × {h} pixels is too large to draw")); + } let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; let (w, h) = d.dimensions(); (d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw")) } /// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as -/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which -/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop -/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or -/// other extra channels, which the decoder can't read either, become RGBA too. +/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per +/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]); +/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color +/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]). +/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read +/// either, become RGBA too, whether they store their channels together or one plane per channel. +/// A TIFF with more strips or tiles than the decoder can count +/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); + if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { + return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); + } if psdread::is_psd(bytes) { let cms = vectorcraft_color::cms::active(); return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi); @@ -469,10 +479,14 @@ pub fn raster_image(bytes: &[u8]) -> Result { reader.limits(limits); let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?; let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; - let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some(); - if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) { + let kept = match f.id { + "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), + "tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi), + _ => None, + }; + if let Some(kept) = kept { let (width, height) = reader.into_dimensions().map_err(err)?; - return stored(bytes.to_vec(), f.mime, (width, height), ppi); + return stored(kept, f.mime, (width, height), ppi); } let extra_samples = || { let cms = vectorcraft_color::cms::active(); diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 3819c842a..92eb5b7fd 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -114,6 +114,100 @@ fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() { assert_eq!(rgb.blob.mime, "image/png"); } +/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a +/// sample, chunky or `planar`. +fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec]) -> Vec { + let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels }; + vectorcraft_testkit::tiff::tiff(&t) +} + +/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image +/// reader decodes from the stored image, as RGBA. +fn kept(what: &str, bytes: &[u8]) -> (Option>, Vec) { + let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}"); + assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution"); + let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw(); + (r.blob.cmyk().map(|i| i.data), rgba) +} + +/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do: +/// grayscale and RGB with extra channels, and CMYK with and without them (#1466). +#[test] +fn planar_tiffs_read_as_their_chunky_versions() { + let ch = |v: [u16; 6]| v.to_vec(); + let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4])); + let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90])); + let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0])); + // Pixel by pixel, the samples of `px`. + let interleave = |px: &[&Vec]| -> Vec { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() }; + let opaque = ch([255; 6]); + let inks = interleave(&[&c, &m, &y, &k]); + // (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK) + type Case = (u16, Vec, Vec>, Option>, Option>); + let cases: Vec = vec![ + (1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None), + (1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None), + (2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None), + (2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None), + (5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())), + (5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())), + (5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None), + ]; + for (photometric, extra, channels, want_rgba, want_inks) in cases { + for bits in [8, 16] { + let wide: Vec> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits"); + let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide)); + let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide)); + assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps"); + assert_eq!(planar.0, want_inks, "{what}: the inks"); + // A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with + // its 8-bit inks: their RGB pixels may be a level apart. + let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1); + assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1); + if let Some(want) = &want_rgba { + assert_eq!(&planar.1, want, "{what}: the pixels"); + } + } + } +} + +/// 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] +fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() { + let plane = |v: u16| vec![v; 6]; + let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]); + // ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the + // eighth, at offset 102). + file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes()); + file[102..106].copy_from_slice(&1u32.to_le_bytes()); + let e = raster_image(&file).err().expect("refused"); + assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}"); + assert!(too_large_note(&file).is_some()); + assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1))); +} + +/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder +/// reads (#1466). +#[test] +fn planar_cmyk_tiffs_open_and_place_with_their_inks() { + let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]]; + let file = hand_tiff(5, 8, &[], true, &inks); + let want: Vec = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect(); + assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now"); + let mut s = Session::new(); + open(&mut s, "planar.tif", &file); + let blob = &s.doc().unwrap().doc.images[&image_of(&s).key]; + assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks"); + let mut s = session(100.0, 100.0, 1); + s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + let blob = &s.doc().unwrap().doc.images[&image_of(&s).key]; + assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks"); + assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image"); +} + #[test] fn content_beats_a_wrong_extension() { let png = image_bytes(2, 2, image::ImageFormat::Png); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index faac93f82..21a877dcc 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1303,6 +1303,17 @@ fn raster_samples() -> Vec<(&'static str, Vec)> { out.push((name, b)); } out[0].1 = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out[0].1, (300.0, 150.0)); + // TIFFs that store one plane per channel: CMYK with a channel after the inks, and gray with + // alpha, 16 bits a sample. + let plane = |v: u16| vec![v; 15]; + let cmyk = [plane(200), plane(40), plane(10), plane(0), plane(90)]; + let cmyk = + vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 5, bits: 8, extra: &[0], planar: true, ppi: Some(300), channels: &cmyk }; + out.push(("planar-cmyk.tif", vectorcraft_testkit::tiff::tiff(&cmyk))); + let gray = [plane(30_000), plane(65_535)]; + let gray = + vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 1, bits: 16, extra: &[2], planar: true, ppi: None, channels: &gray }; + out.push(("planar-gray.tif", vectorcraft_testkit::tiff::tiff(&gray))); // A layered PSD with transparency (as VectorCraft exports it), and a PackBits PSB by hand. let mut s = rich_session(); let r = s.execute("document.export", &json!({"format": "psd", "ppi": 2})).unwrap(); @@ -1325,7 +1336,7 @@ proptest! { /// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading /// their resolution or placing them. #[test] - fn mutated_images_place_without_panics(which in 0usize..6, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { + fn mutated_images_place_without_panics(which in 0usize..8, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::()), 0..12)) { let (name, mut bytes) = raster_samples().swap_remove(which); for (at, b) in edits { if let Some(x) = bytes.get_mut(at) { diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index af8b7d47c..f0091bcd3 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -12,6 +12,7 @@ //! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests. //! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it. //! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests. +//! - [`tiff`]: hand-written TIFF files, chunky or planar, for image import tests. //! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests. //! //! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`). @@ -30,6 +31,7 @@ pub mod invariants; pub mod pdf; pub mod raster; pub mod strategies; +pub mod tiff; pub use serde_json::{Value, json}; pub use vectorcraft_engine::{Session, doc::NodeId}; diff --git a/crates/testkit/src/tiff.rs b/crates/testkit/src/tiff.rs new file mode 100644 index 000000000..d31a66681 --- /dev/null +++ b/crates/testkit/src/tiff.rs @@ -0,0 +1,116 @@ +//! Hand-written TIFF files for import tests: little-endian, uncompressed, 8 or 16 bits a sample, +//! with each pixel's samples stored together (chunky) or one plane per sample (planar, +//! PlanarConfiguration 2) (TIFF 6.0, sections 2 and 8). + +/// One image: its channels in the order the file stores them, color first, extra samples after. +#[derive(Clone, Debug, Default)] +pub struct Tiff<'a> { + pub width: u32, + pub height: u32, + /// PhotometricInterpretation: 0 white is zero, 1 black is zero, 2 RGB, 5 separated (CMYK). + pub photometric: u16, + /// Bits a sample: 8 or 16. + pub bits: u16, + /// The ExtraSamples values, one for each channel after the color (0 not alpha, 1 associated + /// alpha, 2 unassociated alpha); none writes no ExtraSamples tag. + pub extra: &'a [u16], + /// One plane per channel (PlanarConfiguration 2) instead of each pixel's samples together. + pub planar: bool, + /// Pixels per inch, written as XResolution and YResolution with ResolutionUnit 2. + pub ppi: Option, + /// The channels: `width` × `height` samples each, row by row. + pub channels: &'a [Vec], +} + +/// Pads `out` to an even length: TIFF offsets point at word boundaries. +fn align(out: &mut Vec) { + if out.len() % 2 == 1 { + out.push(0); + } +} + +/// A directory entry: its tag, field type, count and value bytes. +type Entry = (u16, u16, usize, Vec); + +/// The TIFF file of `t`: the header, the directory at offset 8, the values longer than four +/// bytes, then the image in one strip a plane. +pub fn tiff(t: &Tiff) -> Vec { + let pixels = usize::try_from(t.width * t.height).unwrap(); + assert!(t.channels.iter().all(|c| c.len() == pixels), "every channel has a sample a pixel"); + assert!(matches!(t.bits, 8 | 16), "8 or 16 bits a sample"); + let mut strips: Vec> = vec![]; + let samples_of = |i: usize| t.channels.iter().map(move |c| c[i]); + let order: Vec> = if t.planar { t.channels.to_vec() } else { vec![(0..pixels).flat_map(samples_of).collect()] }; + for values in order { + strips.push(match t.bits { + 8 => values.iter().map(|&v| u8::try_from(v).unwrap()).collect(), + _ => values.iter().flat_map(|v| v.to_le_bytes()).collect(), + }); + } + let samples = u16::try_from(t.channels.len()).unwrap(); + let counts: Vec = strips.iter().map(|s| u32::try_from(s.len()).unwrap()).collect(); + let entries = |offsets: &[u32]| -> Vec { + let shorts = |tag, v: &[u16]| (tag, 3, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect()); + let longs = |tag, v: &[u32]| (tag, 4, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect()); + let rational = |tag, n: u32| (tag, 5, 1, [n.to_le_bytes(), 1u32.to_le_bytes()].concat()); + // In ascending tag order. + let mut e = vec![ + longs(256, &[t.width]), + longs(257, &[t.height]), + shorts(258, &vec![t.bits; usize::from(samples)]), + shorts(259, &[1]), + shorts(262, &[t.photometric]), + longs(273, offsets), + shorts(277, &[samples]), + longs(278, &[t.height]), + longs(279, &counts), + ]; + if let Some(ppi) = t.ppi { + e.extend([rational(282, ppi), rational(283, ppi)]); + } + e.push(shorts(284, &[if t.planar { 2 } else { 1 }])); + if t.ppi.is_some() { + e.push(shorts(296, &[2])); + } + if !t.extra.is_empty() { + e.push(shorts(338, t.extra)); + } + e + }; + // The strips' offsets follow from the sizes of the rest, which don't depend on them. + let padded = |n: usize| n + n % 2; + let sized = entries(&vec![0; strips.len()]); + let ifd_len = 2 + 12 * sized.len() + 4; + let values_len: usize = sized.iter().filter(|e| e.3.len() > 4).map(|e| padded(e.3.len())).sum(); + let mut at = 8 + ifd_len + values_len; + let mut offsets = vec![]; + for s in &strips { + offsets.push(u32::try_from(at).unwrap()); + at += padded(s.len()); + } + let mut out = b"II*\0\x08\0\0\0".to_vec(); + let entries = entries(&offsets); + out.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + let mut values = vec![]; + for (tag, ty, count, bytes) in entries { + out.extend(tag.to_le_bytes()); + out.extend(ty.to_le_bytes()); + out.extend(u32::try_from(count).unwrap().to_le_bytes()); + if bytes.len() <= 4 { + out.extend(&bytes); + out.extend(vec![0; 4 - bytes.len()]); + } else { + out.extend(u32::try_from(8 + ifd_len + values.len()).unwrap().to_le_bytes()); + values.extend(bytes); + align(&mut values); + } + } + out.extend([0; 4]); + out.extend(values); + for s in strips { + out.extend(s); + align(&mut out); + } + assert_eq!(out.len(), at, "the strips sit where the directory says"); + out +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7b6fb8b40..516e64f71 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -101,6 +101,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (merged image) | no | | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts; CCITT Group 3 doesn't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584) | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | HEIC / HEIF | open, place | no | no | | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1806283db..a3d40fa66 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -617,7 +617,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points. Placed (linked) files in a `.ai`'s editing data come in 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 (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); 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 made taller with a warning in the open result (#1392). - - 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; CCITT Group 3 and planar files with extra channels are not read); 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). + - 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; CCITT Group 3 files are not read); 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`). - 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. - 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; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending. - 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.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 6902e1ab3fae692a39228124eb9be6859b88432c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:49:07 +0700 Subject: [PATCH 069/184] Dialogs: drop the slider wrappers nothing calls since the named ones --- crates/ui-egui/src/dialogs/form.rs | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 82b3cb8db..d158db42a 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -480,20 +480,8 @@ pub(super) fn slider_w( }); } -/// The rail and value field of [`slider`] without its label (for a row that draws its own). -#[allow(dead_code)] -pub(super) fn slider_field( - ui: &mut egui::Ui, - d: &mut Dialog, - key: &str, - range: std::ops::RangeInclusive, - suffix: &str, - track: &dyn Fn(f32) -> egui::Color32, -) { - slider_field_named(ui, d, key, "", range, suffix, track); -} - -/// [`slider_field`] whose rail screen readers call `name` (the row's label, already translated). +/// The rail and value field of [`slider`] without its label (for a row that draws its own); screen +/// readers call the rail `name` (the row's label, already translated). pub(super) fn slider_field_named( ui: &mut egui::Ui, d: &mut Dialog, @@ -511,14 +499,9 @@ pub(super) fn slider_field_named( } } -/// The rail of [`slider_field`] alone: dragging it sets the number `d.fields[key]` to a whole -/// number in `range` (a value past the range shows at its end until the rail moves). -#[allow(dead_code)] -pub(super) fn slider_rail(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: std::ops::RangeInclusive, track: &dyn Fn(f32) -> egui::Color32) { - slider_rail_named(ui, d, key, "", range, track); -} - -/// [`slider_rail`] that screen readers call `name` (already translated). +/// The rail of [`slider_field_named`] alone: dragging it sets the number `d.fields[key]` to a +/// whole number in `range` (a value past the range shows at its end until the rail moves); screen +/// readers call it `name` (already translated). pub(super) fn slider_rail_named( ui: &mut egui::Ui, d: &mut Dialog, From d5e44b321eab58f9c514794cd5e985ade1a3e58b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 21:22:38 -0700 Subject: [PATCH 070/184] Swatches: Alt-click on a swatch recolors the selected gradient stop With a gradient stop selected, an Alt-click on a color swatch painted the inactive Fill or Stroke, as it does with no stop selected. The reference app's Default keyboard shortcuts page lists "Apply swatch color to active (or selected) color stop: Alt-click swatch in the Swatches panel" for the Gradient panel. Now, when the active Fill or Stroke is a linear or radial gradient with a selected stop, an Alt-click on a color swatch or a tint swatch recolors that stop in one undo step, and the stop stays selected. The stop changes as a drop of the same swatch on the Gradient panel's ramp changes it (drop_color and dropped_link in panels/gradient.rs): it becomes fully opaque, and a global or spot color or a tint stays linked, so editing the swatch recolors the stop. With a freeform gradient, the selected point takes the color through paint.freeform.setPoint. With no stop or point selected, Alt-click paints the inactive Fill or Stroke as before. The Fill and Stroke chip popovers show the same swatches and behave the same. Docs: docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/panels/gradient.rs | 17 ++++++ crates/ui-egui/src/panels/swatches.rs | 74 +++++++++++++++++++++++++-- docs/control-protocol.md | 4 +- docs/target-app-parity.md | 1 + 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 0c91c0089..194cf415c 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -82,6 +82,23 @@ pub(crate) fn dropped_link(app: &VectorcraftApp, params: &Value) -> Option<(Stri Some((base, tint)) } +/// Recolor the selected stop of the gradient behind the active proxy with `c`, as dropping the +/// paint of `params` on the stop does (fully opaque, linked by [`dropped_link`]), or the selected +/// point of a freeform gradient; the stop or point stays selected. Returns false, and changes +/// nothing, when the active paint is not a gradient or no stop or point is selected. +pub(crate) fn recolor_selected(app: &mut VectorcraftApp, c: Color, params: &Value) -> bool { + let Paint::Gradient(g) = active_paint(app) else { return false }; + if g.gradient.kind == GradientKind::Freeform { + let Some(i) = app.session.selected_freeform_point().filter(|i| *i < shown_points(&g).points.len()) else { return false }; + edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": super::color_json(&c) })); + return true; + } + let Some(i) = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len()) else { return false }; + let (v, i) = drop_color(&g.gradient, Some(i), 0.0, c, dropped_link(app, params)); + set_stops(app, &v, Some(i), Live::Released); + true +} + /// The stops after dragging stop `i` of `origin` to `offset`: moved, or with `copy` (Alt) a copy /// left there, or the colours swapped with stop `over` when the copy is dropped on it. Returns /// the stops and the stop to select. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 878224283..e45af6b40 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -160,11 +160,18 @@ fn swatch_params(e: &Entry) -> Option { }) } -/// Apply a clicked swatch to the active proxy (Alt: the inactive one). +/// Apply a clicked swatch to the active proxy (Alt: the inactive one). An Alt-click on a color +/// swatch with a gradient stop selected recolors the stop as dropping the swatch on it does, +/// and with a freeform gradient point selected it recolors the point. fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) { - if let Some(params) = swatch_params(e) { - super::apply_click(app, ui, params); + let Some(params) = swatch_params(e) else { return }; + if super::alt_held(ui) + && let Some(c) = drag_of(e, vec![]).color() + && super::gradient::recolor_selected(app, c, ¶ms) + { + return; } + super::apply_click(app, ui, params); } /// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). @@ -1378,6 +1385,67 @@ mod tests { assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone"); } + /// The hex colors of the stops of the selection's gradient fill, and its stroke's color. + fn stops_and_stroke(app: &VectorcraftApp) -> (Vec, String) { + let (fill, stroke) = crate::panels::current_paints(app); + let Paint::Gradient(g) = fill else { panic!("the fill is still a gradient: {fill:?}") }; + (g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(), stroke.color().map(|c| c.to_hex()).unwrap_or_default()) + } + + #[test] + fn alt_click_recolors_the_selected_gradient_stop() { + let mut app = app(); + let ctx = context(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000"})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], "#000000".into()), "the stop, not the stroke"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(app.session.selected_stop(), Some(1), "the stop stays selected"); + assert!(app.session.fill_active, "the Fill proxy stays in front"); + // With no stop selected, Alt-click paints the inactive proxy. + app.run("gradient.selectStop", json!({"index": null})).unwrap(); + click_tile(&mut app, &ctx, "Orange", Modifiers::ALT, 2.0); + let orange = app.session.doc().unwrap().doc.swatch("Orange").and_then(|w| w.paint.color()).unwrap().to_hex(); + assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], orange)); + } + + #[test] + fn alt_click_links_the_selected_stop_to_a_global_swatch() { + let mut app = app(); + let ctx = context(); + app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("a gradient fill") }; + assert_eq!(g.gradient.stops[0].swatch.as_deref(), Some("Red"), "linked as a drop on the ramp links it"); + app.run("swatch.edit", json!({"name": "Red", "color": "#00ff00"})).unwrap(); + assert_eq!(stops_and_stroke(&app).0, ["#00ff00", "#000000"], "editing the swatch recolors the stop"); + } + + #[test] + fn alt_click_recolors_the_selected_freeform_point() { + let mut app = app(); + let ctx = context(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap(); + app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap(); + app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap(); + app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap(); + click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0); + let (fill, stroke) = crate::panels::current_paints(&app); + let Paint::Gradient(g) = fill else { panic!("still a gradient: {fill:?}") }; + assert_eq!(g.freeform.expect("placed points").points[1].color.to_hex(), "#ed1c24"); + assert_eq!(stroke.color().map(|c| c.to_hex()).as_deref(), Some("#000000"), "the stroke keeps its color"); + assert_eq!(app.session.selected_freeform_point(), Some(1), "the point stays selected"); + } + #[test] fn delete_asks_then_removes_the_selection_and_unlinks_art() { let mut app = app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 461d4898b..d0b952e5f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -163,7 +163,9 @@ tool option `stop` (the tool to return to; `tool.setOption {key: "stop", value: samples the colour there into the selected stop (`paint.sampleColor {color, stop}`) and switches back. Dragging a swatch, a Fill/Stroke proxy or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the active proxy) with the paint's params and the object's `ids`; a colour dropped on the panel's ramp adds or recolours -a stop. +a stop. Alt-clicking a color swatch in the Swatches panel recolors the selected stop as a drop on it does +(`paint.editGradient {stops}`), or the selected point of a freeform gradient (`paint.freeform.setPoint`); with no stop +or point selected, it paints the inactive Fill or Stroke. Freeform gradients: double-clicking a point with the Gradient tool opens the same `gradientStop` popover on the selected point (`paint.freeform.selectPoint`); its fields are `color` (hex), `opacity` and `spread` (percentages), applied by diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index de7b0e9c4..660659646 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,6 +579,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there. - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/stroke_visibility.rs`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. + With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **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 more arrowheads, 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. - **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 or Pressure 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 or Pressure from the first at the lightest pressure to the second at the heaviest; 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) 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. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. From a285efda46dbdf5a2c0d85e72c8f344c7e59e28d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:51:25 -0700 Subject: [PATCH 071/184] Print: Print, Print Preview and Print Setup take a print preset by name file.print, print.preview and print.setup took print settings only as settings, so an agent had to read a preset with print.presets.list and copy its settings into the call. They now take preset, the name of [Default] or of a saved print preset in any case. The preset's settings are used instead of the document's, settings applies over them, and print.setup saves them with the document as one undo step. An unknown name is a parameter error that lists the presets. The app's file.print with params passes preset on to the engine. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/print.rs | 32 +++++++--- crates/engine/src/tests_print.rs | 60 +++++++++++++++++++ crates/engine/src/tests_printpreview.rs | 14 +++++ crates/engine/tests/import_fuzz.rs | 3 +- .../src/dialogs/tests_print_presets.rs | 24 +++++++- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/print.rs | 9 +-- docs/mcp.md | 10 +++- docs/target-app-parity.md | 1 + 9 files changed, 137 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4821edebd..68fd5cbda 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Print Preview", [], None, - "{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", + "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", has_doc, preview ), @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{settings?: {…settings of print.setup} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", + "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -57,6 +57,23 @@ pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result Result { + let base = match p.get("preset") { + None | Some(Value::Null) => saved(doc), + Some(Value::String(name)) => { + let set = s.print_preset_settings(name).ok_or_else(|| { + let names: Vec = s.print_presets().into_iter().map(|q| q.name).collect(); + bad(cmd, format!("no print preset named `{name}` (presets: {})", names.join(", "))) + })?; + to_json(&set)? + } + Some(_) => return Err(bad(cmd, "preset must be the name of a print preset")), + }; + settings_over(cmd, base, p) +} + /// The settings `v` (JSON) with `p.settings` over them, checked. pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result { match p.get("settings") { @@ -75,8 +92,9 @@ pub(crate) fn to_json(set: &PrintSettings) -> Result { fn setup(s: &mut Session, p: &Value) -> Result { let doc = &s.doc()?.doc; - let set = to_json(&settings(SETUP, doc, p)?)?; - if p.get("settings").is_some_and(|v| !v.is_null()) && doc.print_setup.as_ref() != Some(&set) { + let set = to_json(&preset_or_saved(SETUP, s, doc, p)?)?; + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if (given("settings") || given("preset")) && doc.print_setup.as_ref() != Some(&set) { let stored = set.clone(); s.edit("Print Setup", |d, _| { d.print_setup = Some(stored); @@ -89,7 +107,7 @@ fn setup(s: &mut Session, p: &Value) -> Result { fn preview(s: &mut Session, p: &Value) -> Result { const C: &str = "print.preview"; let doc = &s.doc()?.doc; - let set = settings(C, doc, p)?; + let set = preset_or_saved(C, s, doc, p)?; super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?; let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?; pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv))); @@ -148,7 +166,7 @@ fn print(s: &mut Session, p: &Value) -> Result { } let postscript = to_postscript(p)?; let doc = &s.doc()?.doc; - let mut set = settings(PRINT, doc, p)?; + let mut set = preset_or_saved(PRINT, s, doc, p)?; if postscript { // PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium. let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty()); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 5094ed8ea..c92e3bf5a 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -66,6 +66,66 @@ fn print_writes_the_saved_setup_with_overrides() { let _ = std::fs::remove_file(&path); } +/// A document with four 300 × 200 artboards, set up to print two copies, and a saved print preset +/// "Sheet" that prints artboard 4 only. +fn four_artboards_and_a_preset() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap(); + s.execute("print.setup", &json!({"settings": {"copies": 2}})).unwrap(); + s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap(); + s +} + +#[test] +fn print_takes_a_print_preset_by_name() { + let mut s = four_artboards_and_a_preset(); + // The preset's settings stand in for the document's: one copy of artboard 4. + let (bytes, v) = print(&mut s, json!({"preset": "sheet"})); + assert_eq!((v["pages"].clone(), vectorcraft_pdf::info(&bytes, None).unwrap().pages.len()), (json!(1), 1)); + let (_, v) = print(&mut s, json!({"preset": "Sheet", "settings": {"copies": 3}})); + assert_eq!(v["pages"], 3, "settings apply over the preset's"); + let (_, v) = print(&mut s, json!({"preset": "[Default]"})); + assert_eq!(v["pages"], 4, "[Default]: every artboard, one copy"); + assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 2, "printing doesn't change the saved setup"); + // A PostScript file too. + let v = s.execute("file.print", &json!({"preset": "Sheet", "format": "postscript"})).unwrap(); + assert_eq!(v["pages"], 1); +} + +#[test] +fn an_unknown_print_preset_is_refused_with_the_presets() { + let mut s = four_artboards_and_a_preset(); + for c in ["file.print", "print.preview", "print.setup"] { + // Agents (MCP, the control channel) may pass `preset`. + s.check_params(c, &json!({"preset": "Sheet", "settings": {}})).unwrap(); + let e = s.execute(c, &json!({"preset": "Nope"})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{c}: {e}"); + assert!(e.to_string().contains("no print preset named `Nope` (presets: [Default], Sheet)"), "{c}: {e}"); + assert!(matches!(s.execute(c, &json!({"preset": 4})), Err(EngineError::BadParams { .. })), "{c}"); + } + assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2); +} + +#[test] +fn setup_with_a_preset_stores_its_settings() { + let mut s = four_artboards_and_a_preset(); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap(); + let got = (v["settings"]["copies"].clone(), v["settings"]["artboards"].clone(), v["settings"]["range"].clone()); + assert_eq!(got, (json!(1), json!("range"), json!("4"))); + let stored = s.doc().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!(stored, v["settings"]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The same preset again is no new step; settings apply over it. + s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let v = s.execute("print.setup", &json!({"preset": "Sheet", "settings": {"collate": false}})).unwrap(); + assert_eq!((v["settings"]["range"].clone(), v["settings"]["collate"].clone()), (json!("4"), json!(false))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2); +} + #[test] fn preview_lists_pages_tiles_inks_and_warnings() { let (mut s, _) = session(); diff --git a/crates/engine/src/tests_printpreview.rs b/crates/engine/src/tests_printpreview.rs index ebe9be9e5..88ebdb627 100644 --- a/crates/engine/src/tests_printpreview.rs +++ b/crates/engine/src/tests_printpreview.rs @@ -52,6 +52,20 @@ fn pages_place_the_artboard_on_the_trim_box() { } } +#[test] +fn a_print_preset_lays_out_its_settings_with_settings_over_them() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap(); + s.execute("print.setup", &json!({"settings": {"media": "a4"}})).unwrap(); + s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap(); + let v = s.execute("print.preview", &json!({"preset": "Sheet", "settings": {"artboards": "range", "range": "3"}})).unwrap(); + assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(2)), "{v}"); + assert_eq!(v["settings"]["media"], "letter", "the preset's paper, not the document's"); + let v = s.execute("print.preview", &json!({"preset": "Sheet"})).unwrap(); + assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(3))); + assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["media"], "a4", "the preview keeps nothing"); +} + /// Point type at (`x`, `y`) in `family` → its id. fn type_in(s: &mut Session, x: f64, y: f64, family: &str) -> u64 { s.execute("text.create", &json!({"x": x, "y": y, "text": "Ab", "font": family, "style": "Regular"})).unwrap()["id"].as_u64().unwrap() diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index a6553a997..3d42d9835 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1932,12 +1932,13 @@ proptest! { // ---------- print presets files ---------- /// Import print presets from `data`, then lay out (and print, when short) the rich document with -/// each one imported. +/// each one imported, by its settings and by its name. fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> { survive_library(what, "print.presets.import", data, |s, r| { let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default(); for name in r["imported"].as_array().into_iter().flatten() { let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue }; + let _ = no_panic(s.execute("print.preview", &json!({ "preset": name }))); let settings = json!({ "settings": p["settings"] }); let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64()); let _ = no_panic(s.execute("print.setup", &settings)); diff --git a/crates/ui-egui/src/dialogs/tests_print_presets.rs b/crates/ui-egui/src/dialogs/tests_print_presets.rs index ae4848fb3..ce70f72db 100644 --- a/crates/ui-egui/src/dialogs/tests_print_presets.rs +++ b/crates/ui-egui/src/dialogs/tests_print_presets.rs @@ -1,5 +1,9 @@ //! Print presets in the UI: the Print dialog's preset list and Save Preset…, and Edit → Print -//! Presets (New…, Edit…, Delete, Import…, Export…) with its preset editor. +//! Presets (New…, Edit…, Delete, Import…, Export…) with its preset editor, and `file.print` with +//! a preset's name. + +use std::cell::RefCell; +use std::rc::Rc; use serde_json::{Value, json}; use vectorcraft_engine::Session; @@ -7,7 +11,7 @@ use vectorcraft_engine::cmd::printpresets::DEFAULT_PRESET; use super::print::{KIND, PRESET_KIND, SECTIONS}; use super::print_presets::{self, Action}; -use crate::{VectorcraftApp, theme}; +use crate::{Services, VectorcraftApp, theme}; fn frame(app: &mut VectorcraftApp) { let ctx = egui::Context::default(); @@ -77,6 +81,22 @@ fn the_print_dialog_saves_and_loads_presets() { assert_eq!(app.session.prefs.print_presets.len(), 1); } +#[test] +fn file_print_with_params_prints_a_preset_by_name() { + let written: Rc>>> = Rc::default(); + let w = written.clone(); + let write = move |_: &str, b: &[u8]| { + w.borrow_mut().push(b.to_vec()); + Ok(()) + }; + let mut app = VectorcraftApp::new(Session::new(), Services { write: Some(Box::new(write)), ..Default::default() }); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 4})).unwrap(); + app.run("print.presets.save", json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap(); + let r = app.run("file.print", json!({"path": "/tmp/sheet.pdf", "preset": "Sheet"})).unwrap(); + assert_eq!(r["pages"], 1, "{r}"); + assert_eq!(vectorcraft_pdf::info(&written.borrow()[0], None).unwrap().pages.len(), 1); +} + #[test] fn the_manager_adds_edits_and_deletes_presets_through_the_editor() { // Edit → Print Presets works without a document too. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 225cfb0cc..b7d32a066 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -812,7 +812,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.print", "Print…", "Cmd+P", - "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {settings? (over the document's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", + "{} open the Print dialog (dialog `print`: the print.setup settings, preset (setting it loads that print preset; Save Preset… saves them as one), printer, toFile; Print keeps the settings with the document and prints, Done (discard: true) only keeps them); with params {preset? (a print preset's name: its settings instead of the document's), settings? (over the document's or the preset's), printer? (print.printers name; \"\" the default), toFile?, path?, format?: pdf|postscript, level?, flattenerPreset? (as engine file.print)} print without it: the print-ready PDF (engine file.print) goes to the printer → {pages, printer, printed: true, warnings}; with toFile, a path or no printing here (no print service) it is saved as a PDF at path (else a picked file, the web downloads it) → {path, pages, printed: false, warnings}; a PostScript file (format postscript, or a .ps path) is always saved that way", ), ( "print.printers", diff --git a/crates/ui-egui/src/print.rs b/crates/ui-egui/src/print.rs index 12e6e73a8..0fcb163da 100644 --- a/crates/ui-egui/src/print.rs +++ b/crates/ui-egui/src/print.rs @@ -58,9 +58,10 @@ pub fn printers(app: &mut VectorcraftApp) -> Value { json!({ "printers": printers, "service": app.services.print.is_some(), "setup": setup }) } -/// `file.print` with settings: print the active document with `settings` (over the document's) -/// on `printer` (default: the system's default), or save the job as a PDF at `path` (else a picked -/// file, the web downloads it) with `toFile`, a `path` or no print service. A PostScript file +/// `file.print` with settings: print the active document with `settings` (over the document's, or +/// over those of the print preset `preset` names) on `printer` (default: the system's default), or +/// save the job as a PDF at `path` (else a picked file, the web downloads it) with `toFile`, a +/// `path` or no print service. A PostScript file /// (`format: "postscript"` or a `.ps` path, at `level`, flattened with `flattenerPreset`) is saved /// the same way. pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { @@ -68,7 +69,7 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { let postscript = s("format").as_deref() == Some("postscript") || s("path").is_some_and(|path| fileio::extension(&path) == "ps"); let to_file = p.get("toFile").and_then(Value::as_bool).unwrap_or(false) || s("path").is_some() || postscript; let mut q = json!({}); - for k in ["settings", "format", "level", "flattenerPreset"] { + for k in ["preset", "settings", "format", "level", "flattenerPreset"] { if let Some(v) = p.get(k).filter(|v| !v.is_null()) { q[k] = v.clone(); } diff --git a/docs/mcp.md b/docs/mcp.md index a1b97ef2d..a9a6f2e29 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2749,7 +2749,8 @@ answers what it would print without writing it: `{pages (copies included), sheet height, orientation, scale}] (one copy, in order), tiles: [{artboard, columns, rows, printed, tiles}] (document space), inks: [{name, spot, print, frequency, angle}], warnings, settings}`. `print.setup {settings?}` saves the settings with the document (one undo step, kept in the native file as `print_setup`); both other commands start from -the saved settings, with `settings` over them (`null` keeps a value). `list_commands` with filter `print.setup` +the saved settings, or from a print preset's with `preset` (see Print presets), with `settings` over them (`null` keeps +a value). `list_commands` with filter `print.setup` documents every field: - General: `copies` (1–999), `collate`, `reverse`; `artboards: all|range|ignore` with `range: "1-3, 5"` (ignore: all @@ -3017,11 +3018,14 @@ preset?, settings?}` creates or changes a saved preset (starting from its own se `settings` over them) and `print.presets.delete {name}` deletes one; `[Default]` can be neither changed nor deleted. `print.presets.export {names?, path?}` writes them as a `.vcprintpresets` JSON file (without `path` it returns `data`), and `print.presets.import {path? | data? | dataBase64?, replace?}` adds a file's presets (a name in use gets a number -unless `replace`). Saved presets live with the preferences. To print with a preset, pass its `settings` to -`print.setup`, `print.preview` or `file.print`. +unless `replace`). Saved presets live with the preferences. `print.setup`, `print.preview` and `file.print` take +`preset`, the name of `[Default]` or of a saved preset in any case: the preset's settings are used instead of the +document's, with `settings` over them, and `print.setup {preset}` saves them with the document. An unknown name is +refused with the list of presets. ```json {"name":"run_command","arguments":{"command":"print.presets.save","params":{"name":"Posters","settings":{"scaling":"tileImageable","overlap":18,"marks":{"trim":true}}}}} +{"name":"run_command","arguments":{"command":"file.print","params":{"preset":"Posters","path":"/tmp/posters.pdf"}}} ``` ## EPS and PostScript import diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 660659646..4cc16ff79 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -622,6 +622,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`). + - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. From 5bdd9d539f1bde9dcbb04b8f74d44b5f63d2ee2c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 18:32:28 -0700 Subject: [PATCH 072/184] =?UTF-8?q?Variables:=20Import=20Data=20Sets?= =?UTF-8?q?=E2=80=A6=20reads=20tab-separated=20files=20and=20files=20witho?= =?UTF-8?q?ut=20a=20name=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dataset.import read only commas between cells and refused a file whose first header cell wasn't `name`. A value that didn't match its variable's kind, and a repeated data set name, were reported with the row's record number, which is not the file line after a blank line or a quoted line break. A data set name over 255 characters gave no line. - When the header row, read with tabs between cells, has a tab and no comma outside quotes, tabs separate the cells. A quoted cell may then hold tabs and commas. - Without a `name` column every header cell is a variable, and the rows take the first free names Data Set 1, Data Set 2 and so on, as Capture Data Set and blank name cells do. A first header cell `name`, in any case, still holds the data set names. - Value errors, a repeated data set name and a name over 255 characters give the file line the row starts on. - The Variables panel's Import Data Sets… dialog also lists .tsv files. - import_fuzz.rs imports random text, random bytes and mutated exports, comma- and tab-separated, into a document with bound text, visibility and graph variables, selects up to three of the imported data sets in turn, and exports every data set. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/variablecmds.rs | 115 ++++++++++++++++++------- crates/engine/src/tests_variables.rs | 66 +++++++++++++- crates/engine/tests/import_fuzz.rs | 63 ++++++++++++++ crates/ui-egui/src/panels/variables.rs | 4 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 6 files changed, 219 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index aae552784..e57a5049e 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -5,8 +5,9 @@ //! variable, or a graph root to a `graph` variable (CSV data), put a row of values in //! each dataset, and `dataset.select` swaps the whole document to that row in one undo //! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name` -//! then variable names). Agents reach every command through `run_command`, like all -//! engine commands. +//! then variable names); import also reads tab-separated files and files without the +//! `name` column. Agents reach every command through `run_command`, like all engine +//! commands. use std::collections::{BTreeMap, BTreeSet}; @@ -535,16 +536,61 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result> { Ok(bytes) } -/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells, -/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as -/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes -/// hold as written, spaces included, and drops white space after the closing quote at the end -/// of the cell. Blank lines are skipped. -fn parse_datasets_csv(text: &str, cmd: &str) -> Result>> { +/// A row of a datasets CSV: the file line it starts on, and its cells. +type CsvRow = (usize, Vec); + +/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that +/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside +/// quotes. +fn tab_separated(text: &str) -> bool { + // `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds + // anything but white space yet. + let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if in_quotes => { + if chars.peek() == Some(&'"') { + chars.next(); + blank = false; + } else { + in_quotes = false; + } + } + '"' if start => in_quotes = true, + c if in_quotes => blank &= c.is_whitespace(), + ',' => return false, + '\t' => { + tab = true; + start = true; + continue; + } + '\r' => continue, + '\n' if blank => { + tab = false; + start = true; + continue; + } + '\n' => return tab, + c => blank &= c.is_whitespace(), + } + start = false; + } + tab +} + +/// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated +/// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside), +/// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted +/// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and +/// drops white space after the closing quote at the end of the cell. Blank lines are skipped. +fn parse_datasets_csv(text: &str, cmd: &str) -> Result> { let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let sep = if tab_separated(text) { '\t' } else { ',' }; let (mut rows, mut row, mut cur) = (Vec::new(), Vec::::new(), String::new()); - // `closed`: where the closing quote of a quoted cell falls in `cur`. - let (mut in_quotes, mut closed, mut line) = (false, None, 1usize); + // `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the + // current row starts on. + let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize); let mut chars = text.chars().peekable(); loop { let c = chars.next(); @@ -559,12 +605,12 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result>> { } } Some('"') if cur.is_empty() => in_quotes = true, - Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)), + Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)), Some('\r') if !in_quotes => {} Some('\n') | None if !in_quotes => { row.push(end_csv_cell(&mut cur, &mut closed)); if row.iter().any(|c| !c.trim().is_empty()) { - rows.push(std::mem::take(&mut row)); + rows.push((start, std::mem::take(&mut row))); // +1 for the header. if rows.len() > MAX_CSV_ROWS + 1 { return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows"))); @@ -575,6 +621,7 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result>> { return Ok(rows); } line += 1; + start = line; } None => return Err(bad(cmd, format!("line {line}: unclosed quote"))), Some(c) => { @@ -659,27 +706,27 @@ fn datasets_to_csv(vars: &Variables) -> String { out } -/// Import datasets from CSV: first column `name`, remaining headers are variable names. -/// Existing rows of the same name are replaced; new names are appended. One undo step. +/// Import datasets from CSV. A first header cell `name` makes the first column the dataset +/// names; the other headers are variable names. Without it, every header is a variable name +/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows +/// of the same name are replaced; new names are appended. One undo step. fn dataset_import(s: &mut Session, p: &Value) -> Result { const C: &str = "dataset.import"; let bytes = datasets_csv_bytes(p, C)?; let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?; let rows = parse_datasets_csv(text, C)?; - let Some(header) = rows.first() else { + let Some((_, header)) = rows.first() else { return Err(bad(C, "CSV is empty")); }; - if header.first().map(|h| h.trim().eq_ignore_ascii_case("name")) != Some(true) { - return Err(bad(C, "first header cell must be `name`")); - } + let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name")); let (kinds, existing_names) = { let st = s.doc()?; let kinds: BTreeMap = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect(); - let existing: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + let existing: BTreeSet = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); (kinds, existing) }; let mut columns: Vec<(usize, String, VariableKind)> = Vec::new(); - for (i, h) in header.iter().enumerate().skip(1) { + for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) { let name = h.trim(); if name.is_empty() { return Err(bad(C, format!("empty header in column {}", i + 1))); @@ -691,19 +738,25 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "CSV has a header but no data rows")); } let mut parsed: Vec = Vec::new(); - for (r_i, row) in rows.iter().enumerate().skip(1) { - let line = r_i + 1; - let raw_name = row.first().map(|c| c.trim()).unwrap_or(""); + // The names of the rows read so far, and the last number given to a row without a name. + let (mut seen, mut number) = (BTreeSet::new(), 0usize); + for &(line, ref row) in rows.iter().skip(1) { + let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" }; let name = if raw_name.is_empty() { - // Same numbering as Capture Data Set when a row leaves the name blank. - (1..) - .map(|n| format!("Data Set {n}")) - .find(|n| parsed.iter().all(|d| d.name != *n) && !existing_names.iter().any(|e| e == n)) - .unwrap_or_else(|| format!("Data Set {line}")) + // Same numbering as Capture Data Set when a row leaves the name blank or the file has + // no name column. The search starts after the last number given. + let (n, name) = (number + 1..) + .map(|n| (n, format!("Data Set {n}"))) + .find(|(_, n)| !seen.contains(n) && !existing_names.contains(n)) + .unwrap_or_else(|| (line, format!("Data Set {line}"))); + number = n; + name + } else if raw_name.chars().count() > MAX_NAME { + return Err(bad(C, format!("line {line}: `name` is longer than {MAX_NAME} characters"))); } else { - named(Some(raw_name), "name", C)? + raw_name.to_string() }; - if parsed.iter().any(|d| d.name == name) { + if !seen.insert(name.clone()) { return Err(bad(C, format!("line {line}: dataset `{name}` appears twice"))); } let mut values = BTreeMap::new(); @@ -937,7 +990,7 @@ pub fn specs() -> Vec { "Import Data Sets…", ["Window", "Variables"], None, - "{path? | data? | dataBase64? | csv?} load datasets from CSV (header: name, then variable names; one row per dataset). Rows whose name already exists are replaced → {imported, updated, names}", + "{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}", has_doc, dataset_import ), diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index b455980c0..b6824f497 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -948,6 +948,70 @@ fn datasets_csv_keeps_spaces_in_quoted_cells() { assert_eq!(value("C"), text_value(" quoted ")); } +/// A header row with a tab and no comma outside quotes makes tabs the separator; a quoted cell +/// may then hold tabs and commas. A header row with a comma outside quotes keeps commas, even +/// when a cell holds a tab. A tab-separated header with an unclosed quote fails with the reason. +#[test] +fn datasets_csv_reads_tab_separated_files() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Last, First", "kind": "text"})).unwrap(); + let tsv = "name\tTitle\tShow\r\nA\tTwo words\ttrue\r\nB\t\"a, b\tc\"\tfalse\r\n"; + let imp = s.execute("dataset.import", &json!({"csv": tsv})).unwrap(); + assert_eq!(imp["names"], json!(["A", "B"])); + s.execute("dataset.import", &json!({"csv": "name,Title\nC,one\ttwo"})).unwrap(); + s.execute("dataset.import", &json!({"csv": "name\t\"Last, First\"\nD\tSmith, Jo"})).unwrap(); + let value = |name: &str, var: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get(var)).cloned(); + let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into())); + assert_eq!(value("A", "Title"), text_value("Two words")); + assert_eq!(value("A", "Show"), Some(vectorcraft_doc::DataValue::Visible(true))); + assert_eq!(value("B", "Title"), text_value("a, b\tc")); + assert_eq!(value("B", "Show"), Some(vectorcraft_doc::DataValue::Visible(false))); + assert_eq!(value("C", "Title"), text_value("one\ttwo")); + assert_eq!(value("D", "Last, First"), text_value("Smith, Jo")); + let unclosed = s.execute("dataset.import", &json!({"csv": "name\tTitle\t\"Show\nA\tx\ttrue"})).unwrap_err().to_string(); + assert!(unclosed.contains("unclosed quote"), "{unclosed}"); +} + +/// Without a `name` column every header cell is a variable, and the rows take the first free +/// names Data Set 1, 2 and so on. A first header cell `name`, in any case, still holds the names. +#[test] +fn datasets_csv_without_a_name_column_numbers_the_rows() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("dataset.new", &json!({"name": "Data Set 2", "values": {"Title": "kept"}})).unwrap(); + let imp = s.execute("dataset.import", &json!({"csv": "Title\nx\ny\n"})).unwrap(); + assert_eq!((imp["imported"].as_u64(), imp["updated"].as_u64()), (Some(2), Some(0))); + assert_eq!(imp["names"], json!(["Data Set 1", "Data Set 3"])); + let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Title")).cloned(); + let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into())); + assert_eq!(value(&s, "Data Set 1"), text_value("x")); + assert_eq!(value(&s, "Data Set 2"), text_value("kept")); + assert_eq!(value(&s, "Data Set 3"), text_value("y")); + let named = s.execute("dataset.import", &json!({"csv": "NAME,Title\nData Set 1,z"})).unwrap(); + assert_eq!((named["imported"].as_u64(), named["updated"].as_u64()), (Some(0), Some(1))); + assert_eq!(value(&s, "Data Set 1"), text_value("z")); +} + +/// An error about a row's values or name gives the file line the row starts on, counting blank +/// lines and line breaks inside quoted cells. +#[test] +fn datasets_csv_row_errors_name_the_file_line() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string(); + let blank_line = error(&mut s, "name,Show\n\nX,maybe"); + assert!(blank_line.contains("line 3:"), "{blank_line}"); + let line_break = error(&mut s, "name,Title,Show\nA,\"one\ntwo\",true\nB,x,maybe"); + assert!(line_break.contains("line 4:"), "{line_break}"); + let twice = error(&mut s, "name,Title\nA,x\n\nA,y"); + assert!(twice.contains("line 4:"), "{twice}"); + let long = error(&mut s, &format!("name,Title\n\n{},x", "n".repeat(256))); + assert!(long.contains("line 3:") && long.contains("longer than 255"), "{long}"); +} + /// Import / export datasets as CSV: header `name` then variables; round-trip keeps values. #[test] fn datasets_csv_round_trips_and_rejects_bad_input() { @@ -989,7 +1053,7 @@ fn datasets_csv_round_trips_and_rejects_bad_input() { assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2))); assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err()); assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err()); - assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "first column must be name"); + assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "no variable named Title"); let out = dir.file("sets.csv"); s.execute("dataset.export", &json!({"path": out})).unwrap(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 3d42d9835..faac93f82 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1157,6 +1157,69 @@ proptest! { } } +// ---------- data set CSV files ---------- + +/// A document with a text, a visibility and a graph variable, each bound to an object, and two +/// data sets. +fn merge_session() -> vectorcraft_engine::Session { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = |r: Value| r["id"].as_u64().unwrap(); + let t = id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One"})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 40, "width": 50, "height": 40})).unwrap()); + let graph = json!({"type": "column", "x": 80, "y": 10, "width": 100, "height": 80, "csv": ",Sales\nA,1\nB,2"}); + let g = id(s.execute("graph.create", &graph).unwrap()); + for (name, kind, object) in [("Title", "text", t), ("Show", "visibility", r), ("Chart", "graph", g)] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + s.execute("variable.bind", &json!({"variable": name, "ids": [object]})).unwrap(); + } + s.execute("dataset.new", &json!({"name": "A", "values": {"Title": "Two, \"quoted\"\nlines", "Show": true, "Chart": ",Q\nX,3"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Title": " padded ", "Show": false}})).unwrap(); + s +} + +/// [`merge_session`]'s data sets exported as CSV, made once. +fn saved_datasets() -> String { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| merge_session().execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string()).clone() +} + +/// `dataset.import` with `params` on [`merge_session`]'s document, the first three data sets it +/// read selected in turn and every data set exported again; the document then renders and exports. +fn datasets(what: &str, params: Value) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = merge_session(); + if let Ok(r) = no_panic(s.execute("dataset.import", ¶ms)) { + for name in r["names"].as_array().into_iter().flatten().take(3) { + let _ = no_panic(s.execute("dataset.select", &json!({"name": name}))); + } + } + let _ = no_panic(s.execute("dataset.export", &json!({}))); + Some((*s.doc().ok()?.doc).clone()) + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n"])) { + datasets("data set CSV garbage", json!({"csv": format!("{head}{s}")}))?; + } + + #[test] + fn dataset_csv_bytes_never_panic(bytes in prop::collection::vec(any::(), 0..300)) { + datasets("data set CSV bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?; + } + + #[test] + fn mutated_dataset_csvs_never_panic(cut in 0usize..200, edits in prop::collection::vec(arb_edit(), 0..10), tabs in any::()) { + let text = saved_datasets(); + let text = if tabs { text.replace(',', "\t") } else { text }; + datasets("mutated data set CSV", json!({"csv": mutate_text(&text, cut, &edits)}))?; + } +} + // ---------- shortcut sets ---------- /// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 48d0c24ba..a256e10af 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -319,9 +319,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// Variables panel: Import Data Sets… — pick a CSV and run `dataset.import`. +/// Variables panel: Import Data Sets… opens a file dialog for a comma- or tab-separated file and runs `dataset.import` on the chosen file. fn import_datasets(app: &mut VectorcraftApp) { - let pick = crate::FilePick { filters: vec![("CSV", &["csv", "txt"])], ..Default::default() }; + let pick = crate::FilePick { filters: vec![("CSV", &["csv", "tsv", "txt"])], ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return }; if let Err(e) = app.run("dataset.import", json!({"path": path})) { app.status(e); diff --git a/docs/mcp.md b/docs/mcp.md index a9a6f2e29..c62d78b51 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1083,7 +1083,10 @@ already walks Image Trace. Short agent entry points: - Data sets: `dataset.list`, `dataset.new {name, values?}`, `dataset.set {name, values?}`, `dataset.rename`, `dataset.delete`, `dataset.capture`, `dataset.update`, `dataset.select {name}`, `dataset.next` / `dataset.prev` (each `dataset.select` / next / prev is one undo step over every bound object), `dataset.import {path|data|dataBase64|csv}` / - `dataset.export {path?}` (CSV: `name`, then one column per variable). + `dataset.export {path?}` (CSV: `name`, then one column per variable). `dataset.import` also reads tab-separated + files, and files whose first header cell isn't `name`: every header cell is then a variable, and the rows take the + first free names `Data Set 1`, `Data Set 2` and so on. An import error about a row's values or name gives the file + line the row starts on. - Batch: `action.batch {commands, source?: folder|datasets, folder?, destination?: none|saveAndClose|folder, destinationFolder?, overrideOpen?, overrideSave?, overrideExport?, includeSubdirs?, stopOnError?}` plays an action on every file in a folder or every data set (the Actions panel menu's Batch…). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4cc16ff79..722302b45 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -415,6 +415,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. + - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on. - JSON-lines control channel with real egui pointer and keyboard injection. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s. From 5a3e833ec887f898c670c58069fcf04dd51c7a91 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:26:28 -0700 Subject: [PATCH 073/184] Type: a click in a Devanagari syllable goes to its nearer end, and shaping is tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A syllable drawn with several glyphs that share one cluster, such as a pre-base i matra and its consonant (कि), had a caret stop at the edge of each glyph. A click just right of the matra, in the left half of the syllable, put the caret after the syllable. byte_in_line now takes one extent for each cluster, and type on a path compares the click with the middle of the cluster, from the start of its first glyph to the end of its last. New tests with an installed OpenType Devanagari font check that क्ष forms one conjunct glyph, that र्क draws the reph after its base, that कि draws the matra before its consonant, that Ka with a nukta matches the precomposed क़, and that each syllable is one cluster with the arrow keys stopping at its ends. They print SKIPPED when no such font is installed. Docs: docs/localization-parity.md (Hindi row), docs/target-app-parity.md. --- crates/text/src/lib.rs | 37 ++++--- crates/text/src/tests_devanagari.rs | 158 ++++++++++++++++++++++++++++ docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 4 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 crates/text/src/tests_devanagari.rs diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e65c1f79f..70e210ed3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -304,16 +304,20 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { /// Byte offset nearest to the text-space point `p` (for clicking/dragging with the Type tool). pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { if layout.on_path { - let best = layout.glyphs.iter().min_by(|a, b| { + let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| { let ca = a.origin + dir(a.angle) * (a.advance * 0.5); let cb = b.origin + dir(b.angle) * (b.advance * 0.5); (p - ca).hypot2().total_cmp(&(p - cb).hypot2()) }); - return match best { - Some(g) if ((p - g.origin).dot(dir(g.angle)) < g.advance * 0.5) != g.rtl => g.byte, - Some(g) => g.byte + g.len, - None => layout.lines.first().map_or(0, |l| l.start), - }; + let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) }; + // `first` and `last` are the first and last glyphs of the cluster. A click before the middle of + // the cluster, measured along the first glyph's direction, goes to the cluster's start. + let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len; + let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g); + let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g); + let along = dir(first.angle); + let span = (last.origin + dir(last.angle) * last.advance - first.origin).dot(along); + return if ((p - first.origin).dot(along) < span * 0.5) != g.rtl { g.byte } else { g.byte + g.len }; } let p = layout.logical_point(p); // Nearest line band (vertical distance first, then distance to the line's column). @@ -349,13 +353,20 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - let nearest = glyphs + // A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant, + // a consonant and its reph) has one caret stop at each end of its glyphs taken together. + let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len()); + for g in glyphs { + let left = layout.logical_point(g.origin).x; + let right = left + g.advance; + match clusters.last_mut() { + Some((l, r, c)) if c.byte == g.byte && c.len == g.len => (*l, *r) = (l.min(left), r.max(right)), + _ => clusters.push((left, right, g)), + } + } + let nearest = clusters .iter() - .flat_map(|g| { - let left = layout.logical_point(g.origin).x; - let right = left + g.advance; - if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] } - }) + .flat_map(|&(left, right, g)| if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] }) .min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs())); nearest.map_or(line.start, |(_, byte)| byte) } @@ -518,6 +529,8 @@ mod tests_bidi; #[cfg(test)] mod tests_combos; #[cfg(test)] +mod tests_devanagari; +#[cfg(test)] mod tests_embed; #[cfg(test)] mod tests_fit; diff --git a/crates/text/src/tests_devanagari.rs b/crates/text/src/tests_devanagari.rs new file mode 100644 index 000000000..de238ee05 --- /dev/null +++ b/crates/text/src/tests_devanagari.rs @@ -0,0 +1,158 @@ +//! Devanagari shaping: conjuncts, reph, pre-base matras and nukta forms, with their source clusters +//! and caret stops. The expected forms come from Microsoft's OpenType Devanagari shaping specification +//! (https://learn.microsoft.com/en-us/typography/script-development/devanagari) and the Unicode +//! Standard's Devanagari section (https://www.unicode.org/versions/latest/core-spec/chapter-12/). +//! No font is committed: the tests use an installed face and print SKIPPED when none is found. +use super::*; +use kurbo::{BezPath, Point}; +use std::sync::Arc; +use vectorcraft_doc::{CharStyle, TextKind}; +use vectorcraft_geom::PathData; + +fn devanagari_test_face(test: &str) -> Option> { + let db = FontDb::global(); + // The face must have OpenType (GSUB) Devanagari lookups. An AAT face such as macOS's Devanagari MT + // shapes with `morx`, and हिन्दी comes out as three clusters because the conjunct न्दी is split in two. + let opentype = |face: &FontFace| { + use skrifa::raw::TableProvider; + face.skrifa() + .and_then(|f| f.gsub().ok()) + .and_then(|gsub| gsub.script_list().ok()) + .is_some_and(|list| list.script_records().iter().any(|r| matches!(&r.script_tag().to_be_bytes(), b"dev2" | b"deva"))) + }; + let face = ["Kohinoor Devanagari", "ITF Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Nirmala UI", "Mangal"] + .into_iter() + .filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name)) + .find(|face| { + ['क', 'ष', 'र', 'ह', 'न', 'द', '\u{094D}', '\u{093F}', '\u{0940}', '\u{093C}'].iter().all(|&c| face.covers(c)) && opentype(face) + }); + if let Some(face) = &face { + let path = face.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string()); + eprintln!("ASSERTIONS RUN: {test}; Devanagari font {} {} ({path})", face.family, face.style); + } else { + eprintln!("SKIPPED: {test}; no installed candidate font shapes Devanagari through GSUB"); + } + face +} + +fn devanagari_layout(face: &FontFace, text: &str) -> TextLayout { + let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + layout(FontDb::global(), &TextObject::point(Point::ZERO, text, st)) +} + +fn source_clusters(layout: &TextLayout) -> Vec<(usize, usize)> { + let mut clusters: Vec<_> = layout.glyphs.iter().map(|glyph| (glyph.byte, glyph.len)).collect(); + clusters.dedup(); + clusters +} + +/// The left and right edges of the glyphs of the cluster that starts at `byte`. +fn cluster_extent(layout: &TextLayout, byte: usize) -> (f64, f64) { + let glyphs = layout.glyphs.iter().filter(|g| g.byte == byte); + glyphs.fold((f64::INFINITY, f64::NEG_INFINITY), |(left, right), g| (left.min(g.origin.x), right.max(g.origin.x + g.advance))) +} + +#[test] +fn ka_virama_ssa_forms_one_conjunct_glyph() { + let Some(face) = devanagari_test_face("ka_virama_ssa_forms_one_conjunct_glyph") else { return }; + // `akhn` substitutes Ka + halant + Ssa with the KaSsa ligature. + let l = devanagari_layout(&face, "क्ष"); + assert_eq!(l.glyphs.len(), 1, "{:?}", l.glyphs); + let g = &l.glyphs[0]; + assert_eq!((g.byte, g.len), (0, "क्ष".len())); + assert!(!['क', 'ष', '\u{094D}'].iter().any(|&c| face.glyph_for(c) == g.gid), "conjunct drawn with a nominal glyph: {}", g.gid); +} + +#[test] +fn reph_is_drawn_after_its_base_consonant() { + let Some(face) = devanagari_test_face("reph_is_drawn_after_its_base_consonant") else { return }; + // Ra + halant before the base consonant becomes the reph (`rphf`), reordered after the base. + let s = "र्क"; + let l = devanagari_layout(&face, s); + let gids: Vec = l.glyphs.iter().map(|g| g.gid).collect(); + assert_eq!(gids.len(), 2, "{:?}", l.glyphs); + assert_eq!(gids[0], face.glyph_for('क'), "the base consonant comes first: {gids:?}"); + assert!(![face.glyph_for('र'), face.glyph_for('\u{094D}'), face.glyph_for('क')].contains(&gids[1]), "no reph glyph: {gids:?}"); + assert!(l.glyphs[1].origin.x >= l.glyphs[0].origin.x); + assert_eq!(source_clusters(&l), vec![(0, s.len())]); +} + +#[test] +fn pre_base_matra_is_drawn_before_its_consonant() { + let Some(face) = devanagari_test_face("pre_base_matra_is_drawn_before_its_consonant") else { return }; + let s = "कि"; + let l = devanagari_layout(&face, s); + assert_eq!(l.glyphs.len(), 2, "{:?}", l.glyphs); + let (matra, ka) = (&l.glyphs[0], &l.glyphs[1]); + assert_eq!(ka.gid, face.glyph_for('क')); + assert_ne!(matra.gid, ka.gid); + assert!(matra.origin.x < ka.origin.x, "the i matra is drawn to the left: {:?}", l.glyphs); + assert_eq!(source_clusters(&l), vec![(0, s.len())]); + // The caret stops at the syllable's ends: before the matra, and after the consonant. + let (left, right) = cluster_extent(&l, 0); + assert!((caret_position(&l, 0).0.x - left).abs() < 1e-9); + assert!((caret_position(&l, s.len()).0.x - right).abs() < 1e-9); +} + +#[test] +fn syllables_map_to_their_source_bytes_and_caret_stops() { + let Some(face) = devanagari_test_face("syllables_map_to_their_source_bytes_and_caret_stops") else { return }; + // हि + न्दी: the second syllable's matra stays with the conjunct it follows. + let s = "हिन्दी"; + let l = devanagari_layout(&face, s); + let second = "हि".len(); + assert_eq!(source_clusters(&l), vec![(0, second), (second, s.len() - second)]); + // The first syllable's i matra is drawn before its consonant. + assert_eq!(l.glyphs[1].gid, face.glyph_for('ह'), "{:?}", l.glyphs); + assert!(l.glyphs[0].origin.x < l.glyphs[1].origin.x, "{:?}", l.glyphs); + // Left and Right arrows pass over the same syllables the shaper made. + assert_eq!(edit::next_cluster(s, 0), second); + assert_eq!(edit::next_cluster(s, second), s.len()); + assert_eq!(edit::prev_cluster(s, s.len()), second); + assert_eq!(caret_horizontal(&l, 0, true), second); + assert_eq!(caret_horizontal(&l, second, true), s.len()); + assert_eq!(caret_horizontal(&l, s.len(), false), second); + assert!((caret_position(&l, second).0.x - cluster_extent(&l, 0).1).abs() < 1e-9); +} + +#[test] +fn nukta_forms_match_the_precomposed_letters() { + let Some(face) = devanagari_test_face("nukta_forms_match_the_precomposed_letters") else { return }; + // U+0958 is canonically equivalent to Ka + nukta and is drawn the same way. + let composed = devanagari_layout(&face, "\u{0958}"); + let decomposed = devanagari_layout(&face, "\u{0915}\u{093C}"); + let shape = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.advance, g.origin)).collect::>(); + assert_eq!(shape(&composed), shape(&decomposed)); + assert_eq!(source_clusters(&decomposed), vec![(0, "\u{0915}\u{093C}".len())]); + // The nukta joins its consonant: one glyph takes up room on the line. + assert_eq!(decomposed.glyphs.iter().filter(|g| g.advance > 0.0).count(), 1, "{:?}", decomposed.glyphs); +} + +#[test] +fn a_click_in_a_syllable_goes_to_its_nearer_end() { + let Some(face) = devanagari_test_face("a_click_in_a_syllable_goes_to_its_nearer_end") else { return }; + let s = "कि"; + let l = devanagari_layout(&face, s); + let (left, right) = cluster_extent(&l, 0); + // This point lies just past the matra, still in the left half of the syllable. + let matra_end = l.glyphs[0].origin.x + l.glyphs[0].advance; + let near_left = matra_end + 0.1; + assert!(near_left < (left + right) * 0.5, "the matra takes most of the syllable: {:?}", l.glyphs); + let y = l.lines[0].baseline; + assert_eq!(hit_byte(&l, Point::new(near_left, y)), 0); + assert_eq!(hit_byte(&l, Point::new(right - 0.1, y)), s.len()); + + // Clicks at the same places on a path go to the same ends. + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, s, st); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let p = layout(FontDb::global(), &t); + assert!(p.on_path && p.glyphs.len() == 2, "{:?}", p.glyphs); + let x0 = p.glyphs[0].origin.x; + let matra_end = x0 + p.glyphs[0].advance; + assert_eq!(hit_byte(&p, Point::new(matra_end + 0.1, 50.0)), 0); + assert_eq!(hit_byte(&p, Point::new(p.glyphs[1].origin.x + p.glyphs[1].advance - 0.1, 50.0)), s.len()); +} diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7030beacf..d44b53f02 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -26,7 +26,7 @@ Chinese by `imachus`). None is recorded as reviewed by a native speaker; the cat | English | `en` | source, 100% | all | Latin | — | full | — | | Simplified Chinese (Mandarin) | `zh-hans` | 3,647 rows; menus, panels, dialogs complete (tests); 85% overall | messages in English (1 of 604) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | partial | 2–3 | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | -| Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), untested for Devanagari, but the egui interface does no complex shaping | no | none | 20–30 | +| Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), with tests of conjuncts, reph, pre-base matras, nukta forms and caret stops that run when an OpenType Devanagari font is installed, but the egui interface does no complex shaping | no | none | 20–30 | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | | Portuguese | `pt-br` | 4,707 catalog entries: menus, `tl!` literals and messages complete (tests); one|other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 722302b45..1806283db 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -557,6 +557,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. A double-click on area type's bottom middle handle turns on Auto Size (#1940). Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). +- **Devanagari shaping:** with an OpenType Devanagari font, Ka, virama and Ssa (क्ष) form one conjunct glyph, Ra and virama before a consonant (र्क) become a reph drawn after it, the i matra (कि) is drawn before its consonant, and Ka with a nukta is drawn like the precomposed क़ (U+0958), as [Microsoft's Devanagari shaping specification](https://learn.microsoft.com/en-us/typography/script-development/devanagari) describes. Each syllable is one cluster of its source bytes, and the arrow keys stop at its ends. A click inside a syllable drawn with several glyphs puts the caret at the syllable's nearer end, in point, area and path type. Tests: `crates/text/src/tests_devanagari.rs`; they print SKIPPED when no such font is installed. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests_vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). From bf785840e51253070f3df754375b84547c0b0d29 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:52:03 +0700 Subject: [PATCH 074/184] Docs: file-format-parity keeps one PNG/TIFF row with both notes --- docs/file-format-parity.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 516e64f71..ec4aef929 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -101,8 +101,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | | PSD | open, place, export | yes (merged image, every mode and depth) | yes (layered, max editability) | placing as layers (Photoshop Import Options) missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (merged image) | no | | -| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts; CCITT Group 3 doesn't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584) | -| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), CCITT Group 3 and planar files with extra channels don't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | +| PNG / JPEG / GIF / BMP / TIFF / Targa | open, export | yes (Targa: export only) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa import missing; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts; CCITT Group 3 doesn't; the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | From c5d2297ef1de79e64e9561af676f87fc5217c86c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:28:48 -0700 Subject: [PATCH 075/184] Docs: type-parity and the roadmap list the Hyphenation options as done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/type-parity.md and the Set type row of docs/roadmap.md still listed the Hyphenation… options as missing, although the Hyphenation dialog (#2310) sets them for each paragraph. The type-parity row is now partial and names Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized Words. Justification… stays missing. The Set type row of roadmap.md lists only the justification options as missing. Its score is unchanged. docs/target-app-parity.md and docs/gaps.md are left as they are. --- docs/roadmap.md | 2 +- docs/type-parity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index dcaf625a9..0d9904586 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,7 +21,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. | | Draw and edit paths precisely: Pen, Curvature, Direct Selection on anchors and handles, smart guides and snapping, Pathfinder | yes | `crates/tools/src/pen.rs`, `direct.rs`, `guides.rs` and their tests; 10 exact Pathfinder booleans; [ui-parity.md](ui-parity.md). Shape Builder misses regions in some files (#937), Pathfinder covers the same results | 0 | | Paint and style: fills and strokes, global and spot swatches, gradients, Appearance, transparency, graphic styles | yes | M3 done (92 tasks); area scores 92–97% in [target-app-parity.md](target-app-parity.md#feature-areas) | 0 | -| Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); hyphenation and justification options missing (not blocking) | 0 | +| Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | | Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951) and lose guides (#779); Save As `.ai` is PDF-compatible, which Illustrator opens without its own editing data (#868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | diff --git a/docs/type-parity.md b/docs/type-parity.md index beb684425..3a00a019a 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -33,7 +33,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Variable fonts | partial | named instances as styles; axis sliders missing | | Colour fonts (OpenType SVG / COLR) | partial | | | Every-line and Single-line composers | done | tuned by eye, never compared side by side | -| Hyphenation… and Justification… options | missing | | +| Hyphenation… and Justification… options | partial | Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310); Justification… missing (menu stub) | | Optical Margin Alignment | missing | menu stub | | Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | From 1e87e85ab7ae3f70e68f00db2aeb579f93258c7d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 15:46:37 -0700 Subject: [PATCH 076/184] i18n: Simplified Chinese translates status-bar and error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Simplified Chinese catalog had 20 `@msg` rows, against 714 to 718 in each catalog listed in COMPLETE_MESSAGES, so with the interface in Simplified Chinese most status-bar and error messages showed in English. - zh-hans.tsv gains the 694 missing `@msg` rows in one block with a header comment on its conventions: no final 。 unless the English ends with a period, a space between Chinese and adjacent Latin text, numbers or placeholders, and 、 between list items. The rows use the catalog's existing terms (画板, 图稿, 编组, 描边, 色板, 实时上色, 图像描摹, 增效工具 ...). - zh-hans joins COMPLETE_MESSAGES, so complete_languages_translate_every_message and the narrower message tests fail when a zh-hans message row is missing, and a new message needs a zh-hans row. - The registry comment, docs/development.md (which also gains Greek, already in COMPLETE_MESSAGES), the zh-hans row of docs/localization-parity.md, and the interface-languages line of docs/target-app-parity.md (which also gains Czech, Greek, Hungarian, Swedish and Finnish) list Simplified Chinese among the languages that translate messages. --- crates/ui-egui/src/i18n/mod.rs | 4 +- crates/ui-egui/src/i18n/tests.rs | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 700 ++++++++++++++++++++++++++++ docs/development.md | 2 +- docs/localization-parity.md | 2 +- docs/target-app-parity.md | 2 +- 6 files changed, 706 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 9698fb82a..d5cc3a5a8 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -223,8 +223,8 @@ pub static LANGUAGES: [LangInfo; 23] = [ complete_menus: true, catalog: OnceLock::new(), }, - // Simplified Chinese in the vocabulary used in mainland China; `zh-CN`, `zh-SG`, `zh-Hans-*` and a - // bare `zh` resolve here (see `candidates`). + // Simplified Chinese in the vocabulary used in mainland China: the whole interface and the status + // and error messages; `zh-CN`, `zh-SG`, `zh-Hans-*` and a bare `zh` resolve here (see `candidates`). LangInfo { code: "zh-hans", name: "简体中文", diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 3ab33db42..a17831fbe 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1290,7 +1290,7 @@ fn select_key_param_error_is_translated() { } /// Languages whose catalogs cover every status and error message. -const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "fi"]; +const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "fi"]; /// Crates whose error and status messages reach the status bar. const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"]; diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c1e8299d3..cdb7f3f12 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3381,6 +3381,706 @@ imageTrace Outline 轮廓 Set: 集: Shortcut 快捷键 Use Default 使用默认值 +# Status and error messages (`@msg`), translated where the status bar shows them; the message itself +# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order +# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. +# Simplified Chinese: no final 。 unless the English ends with a period; a space between Chinese text and +# an adjacent Latin word, number or {placeholder}; 、 between the items of a list. +@msg `{key}` must be an array of these names: {_1} `{key}` 必须是由以下名称组成的数组:{_1} +@msg Reset {_1} 重置 {_1} +@msg Saving 正在保存 +@msg Saving {_1} 正在保存 {_1} +@msg Sent to {_1} 已发送到 {_1} +@msg `to` must be "corner" or "smooth" `to` 必须是 "corner" 或 "smooth" +@msg a layer couldn't be read 无法读取某个图层 +@msg it has more than one artboard 它有多个画板 +@msg its editing data has no layers 其编辑数据中没有图层 +@msg its editing data is damaged 其编辑数据已损坏 +@msg its type is to come in as outlines, which only its page has 其文字需要以轮廓导入,而只有其页面中包含这些轮廓 +@msg {_1} files {_1} 文件 +@msg A browser with WebGPU or WebGL2 is required. 需要支持 WebGPU 或 WebGL2 的浏览器。 +@msg Loading VectorCraft… 正在加载 VectorCraft… +@msg Reload the page to go on (changes not saved yet may be lost). 请重新加载页面以继续 (尚未保存的更改可能会丢失)。 +@msg The graphics were lost and couldn't be restarted. 图形已丢失,且无法重新启动。 +@msg The graphics were lost and have been restarted 图形已丢失,现已重新启动 +@msg VectorCraft failed to start: {e} VectorCraft 启动失败:{e} +@msg Your unsaved changes are kept in this browser: reload the page to get them back. 您未保存的更改保留在此浏览器中:重新加载页面即可找回。 +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} 首先尝试的图形处理器无法显示窗口,因此 VectorCraft 已在 {summary} 上重新启动 +@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次启动时在其窗口出现之前于 {name} 上终止,因此本次在 {summary} 上启动。要再次尝试该图形处理器,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 +@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次启动时在列出图形处理器期间终止,因此本次仅使用 OpenGL 在 {summary} 上启动。要再次尝试其他图形后端,请更改“首选项 › 性能 › 图形处理器”,或在设置 WGPU_BACKEND 后启动 +@msg not a DXF drawing 不是 DXF 图纸 +@msg scale must be a number from {_1} to {_2}, not {s} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {s} +@msg the artboard to fit must be a positive size up to {_1} pt 要适合的画板尺寸必须为正数,且不超过 {_1} pt +@msg the drawing has no layout `{_1}` (it has {_2}) 图纸中没有布局 `{_1}` (它包含 {_2}) +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 这是 DWG 图纸,无法读取:请在 CAD 应用中将其保存为 ASCII DXF,然后打开该文件 +@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 按此比例,图纸为 {w} × {h} pt,超出画布的容量:请选择较小的比例,或使其适合画板 +@msg the scale is not a usable number 比例不是可用的数字 +@msg the drawing is too large to open 图纸过大,无法打开 +@msg scale must be a number from {_1} to {_2}, not {_3} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} +@msg image '{key}' has no pixels and was left out 图像 '{key}' 没有像素,已略去 +@msg Embedded {n} image(s) 已嵌入 {n} 张图像 +@msg object {0} cannot have children 对象 {0} 不能包含子对象 +@msg range `{s}` is empty 范围 `{s}` 为空 +@msg range `{s}`: `{part}` names no number 范围 `{s}`:`{part}` 未指定数字 +@msg range `{s}`: `{part}` runs backwards 范围 `{s}`:`{part}` 的顺序颠倒 +@msg range `{s}`: `{t}` is not a number from 1 to {count} 范围 `{s}`:`{t}` 不是 1 到 {count} 之间的数字 +@msg not a graphic style library (format `{_1}`) 不是图形样式库 (格式为 `{_1}`) +@msg Show All Hidden Attributes: nothing is hidden 显示所有隐藏属性:没有隐藏任何内容 +@msg Expand: no brush strokes selected 扩展:未选择画笔描边 +@msg no brush named `{name}` 没有名为 `{name}` 的画笔 +@msg no brush selected 未选择画笔 +@msg no symbol instances selected 未选择符号实例 +@msg select a symbol instance 请选择一个符号实例 +@msg the art has no bounds 图稿没有边界 +@msg Image Trace: the traced image is missing 图像描摹:缺少所描摹的图像 +@msg Live Paint Merge: select a Live Paint group and the paths to add 实时上色合并:请选择一个实时上色组以及要添加的路径 +@msg Live Paint: select paths or compound paths 实时上色:请选择路径或复合路径 +@msg Live Paint: these paths are too degenerate to paint 实时上色:这些路径过于退化,无法上色 +@msg Shape Builder: no region under the pointer 形状生成器:指针下方没有区域 +@msg Shape Builder: select paths or compound paths 形状生成器:请选择路径或复合路径 +@msg image data `{_1}` is missing 缺少图像数据 `{_1}` +@msg no Live Paint edge near that point 该点附近没有实时上色边缘 +@msg no Live Paint face at that point 该点处没有实时上色表面 +@msg select a Live Paint group 请选择一个实时上色组 +@msg select an Image Trace object 请选择一个图像描摹对象 +@msg select objects, not layers 请选择对象,而不是图层 +@msg Blend colors: select at least three objects with filled colours 混合颜色:请至少选择三个填有颜色的对象 +@msg no saved themes 没有已保存的主题 +@msg `{keep}` is not a side of this axis `{keep}` 不是此轴的一侧 +@msg unknown side `{other}` (left|right|top|bottom) 未知的侧边 `{other}` (left|right|top|bottom) +@msg an object in perspective would leave the edited grid 透视中的对象将离开所编辑的网格 +@msg nothing to warp 没有可变形的内容 +@msg select an object in perspective 请选择透视中的对象 +@msg select the artwork to warp 请选择要变形的图稿 +@msg select the objects to transform in perspective 请选择要在透视中变换的对象 +@msg select type in perspective 请选择透视中的文字 +@msg the object has no geometry 该对象没有几何形状 +@msg the object has no stroke 该对象没有描边 +@msg the object is beyond the plane's horizon 该对象位于平面的地平线之外 +@msg the object isn't in perspective 该对象不在透视中 +@msg the object isn't on a perspective plane 该对象不在透视平面上 +@msg the object would cross the horizon of the perspective plane 该对象将越过透视平面的地平线 +@msg the objects would leave the grid 这些对象将离开网格 +@msg the pointer is beyond the plane's horizon 指针位于平面的地平线之外 +@msg the selection isn't in perspective 所选对象不在透视中 +@msg the stroke has no weight 描边没有粗细 +@msg {command} didn't create an object {command} 未创建对象 +@msg Make Guides: select paths 创建参考线:请选择路径 +@msg Release Guides: no guides 释放参考线:没有参考线 +@msg guides are locked 参考线已锁定 +@msg no saved selection recalled 未调出任何已保存的选区 +@msg no saved selections 没有已保存的选区 +@msg no such artboard 没有此画板 +@msg cannot split an open path at its end point 无法在开放路径的端点处拆分该路径 +@msg closing needs three anchors 闭合需要三个锚点 +@msg empty brush stroke 画笔描边为空 +@msg no such anchor 没有此锚点 +@msg no such segment 没有此线段 +@msg no such subpath 没有此子路径 +@msg object {id} is not a path 对象 {id} 不是路径 +@msg object {id} is not a selected top-level object 对象 {id} 不是选中的顶层对象 +@msg nothing to redo 没有可重做的操作 +@msg nothing to undo 没有可撤销的操作 +@msg Expand Appearance: the selection has a basic appearance 扩展外观:所选对象只有基本外观 +@msg select (or target) objects whose appearance isn't basic 请选择 (或定位) 外观不是基本外观的对象 +@msg {C}: no effect at index {from} {C}:索引 {from} 处没有效果 +@msg Expand: no gradient fills 扩展:没有渐变填色 +@msg Expand: nothing to expand 扩展:没有可扩展的内容 +@msg batch step {_1} (`{id}`) failed: {e} 批处理步骤 {_1} (`{id}`) 失败:{e} +@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP 文件为 RGB 或灰度:colorModel rgb 或 gray +@msg Targa files are RGB: colorModel rgb Targa 文件为 RGB:colorModel rgb +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`:none、art 或 type +@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}:transparent、white、black 或颜色,例如 "#ff8800" +@msg no artboard {i} (0-based; the document has {count}) 没有画板 {i} (从 0 开始计数;文档有 {count} 个) +@msg no artboards named 未指定画板 +@msg the document has no artboard 文档没有画板 +@msg this format holds one artboard: export several as PDF or with document.exportForScreens 此格式只能包含一个画板:要导出多个画板,请导出为 PDF 或使用 document.exportForScreens +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) 此文件保存时未启用 PDF 兼容性,且无法读取其编辑数据 ({why}) +@msg the export wrote no file 导出未写入任何文件 +@msg {_1} files can be opened but not written (see document.formats) {_1} 文件可以打开,但不能写入 (请参阅 document.formats) +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}:此处没有文件系统 (要打开文件,请传入 dataBase64;省略路径可获取 dataBase64) +@msg Save writes {_1}, not {_2}: use document.export for other formats 保存会写入 {_1},而不是 {_2}:其他格式请使用 document.export +@msg no changes since the last save 自上次保存以来没有更改 +@msg unknown format `{f}` (Save writes {_1}) 未知格式 `{f}` (保存会写入 {_1}) +@msg `svg` must be an object of SVG options (see document.formats) `svg` 必须是由 SVG 选项组成的对象 (请参阅 document.formats) +@msg no end record 没有结束记录 +@msg not a stored central record 中央目录记录不是未压缩 (stored) 方式 +@msg truncated 已截断 +@msg {name}: CRC mismatch {name}:CRC 不匹配 +@msg {name}: no local header {name}:没有本地文件头 +@msg Flatten Transparency: select objects to flatten 拼合透明度:请选择要拼合的对象 +@msg Flatten Transparency: {e} 拼合透明度:{e} +@msg balance must be between 0 and 100 平衡必须介于 0 到 100 之间 +@msg resolutions must be between 1 and 2400 ppi 分辨率必须介于 1 到 2400 ppi 之间 +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知预设 `{name}` (high、medium、low 或已保存的预设:请参阅 flattener.presets.list) +@msg `at` must be [x, y] `at` 必须是 [x, y] +@msg `color` must be a colour `color` 必须是颜色 +@msg `freeform.points` must be an array `freeform.points` 必须是数组 +@msg `freeform.lines` must be an array `freeform.lines` 必须是数组 +@msg `{key}` must be a number `{key}` 必须是数字 +@msg a freeform gradient keeps at least one point 任意形状渐变至少保留一个点 +@msg freeform point {i} needs `at` [x, y] and a valid `color` 任意形状点 {i} 需要 `at` [x, y] 和有效的 `color` +@msg freeform line {i} must be an array of point indices 任意形状线 {i} 必须是由点索引组成的数组 +@msg no point {i} (the gradient has {_1}) 没有点 {i} (该渐变有 {_1} 个) +@msg no segment {segment} on line {line} 线 {line} 上没有线段 {segment} +@msg unknown freeform mode `{m}` (points, lines) 未知的任意形状模式 `{m}` (points、lines) +@msg `aspect` must be a number (%) `aspect` 必须是数字 (%) +@msg `gradient` must be an object `gradient` 必须是对象 +@msg `mode` applies to freeform gradients `mode` 仅适用于任意形状渐变 +@msg `stops` must be an array `stops` 必须是数组 +@msg `{k}` must be [x, y] `{k}` 必须是 [x, y] +@msg a gradient needs at least two stops 渐变至少需要两个色标 +@msg give both `start` and `end` [x, y] (or neither) 请同时给出 `start` 和 `end` [x, y] (或都不给出) +@msg stop {i} needs `offset` 色标 {i} 需要 `offset` +@msg stop {i} needs a valid `color` 色标 {i} 需要有效的 `color` +@msg stop {i}: `{k}` must be a number 色标 {i}:`{k}` 必须是数字 +@msg unknown gradient kind `{k}` (linear, radial, freeform) 未知的渐变类型 `{k}` (linear、radial、freeform) +@msg the art could not be sampled 无法对图稿取样 +@msg the art is too light for any dots 图稿太浅,无法生成任何网点 +@msg the document has no symbols 文档中没有符号 +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` 不是颜色 (#rrggbb 或预设名称) +@msg a colour index is 0 to {_1}, not {n} 颜色索引应为 0 到 {_1},而不是 {n} +@msg a colour is an index, #rrggbb or a preset name, not {v} 颜色应为索引、#rrggbb 或预设名称,而不是 {v} +@msg a document needs at least one layer 文档至少需要一个图层 +@msg no artwork 没有图稿 +@msg no selection bounds 所选对象没有边界 +@msg the layer has no objects to clip by 该图层中没有可用于剪切的对象 +@msg the layer or group is locked 该图层或编组已锁定 +@msg the guide is on a hidden or locked layer 该参考线位于隐藏或锁定的图层上 +@msg the target layer is locked or hidden 目标图层已锁定或隐藏 +@msg the layer to flatten into is hidden 要拼合到的图层已隐藏 +@msg there are no objects to release 没有可释放的对象 +@msg no library `{id}` 没有库 `{id}` +@msg {path}: not a file {path}:不是文件 +@msg {path}: {e} {path}:{e} +@msg can't write .{ext} images 无法写入 .{ext} 图像 +@msg Replace Spine: select a blend and a path 替换混合轴:请选择一个混合对象和一条路径 +@msg nothing to wrap 没有可绕排的内容 +@msg select a blend 请选择一个混合对象 +@msg select a gradient mesh 请选择一个渐变网格 +@msg the object has no opacity mask 该对象没有不透明度蒙版 +@msg Convert to Artboards: selection has no area 转换为画板:所选对象没有面积 +@msg Convert to Shape: no rectangles or ellipses selected 转换为形状:未选择矩形或椭圆 +@msg image would be too large; lower the resolution 图像将过大;请降低分辨率 +@msg clipboard is empty 剪贴板为空 +@msg no anchor points selected 未选择锚点 +@msg select at least two objects 请至少选择两个对象 +@msg no saved presets 没有已保存的预设 +@msg compound paths can only contain paths 复合路径只能包含路径 +@msg no bounds 没有边界 +@msg no current layer 没有当前图层 +@msg no key object 没有关键对象 +@msg no previous transform 没有上一次变换 +@msg nothing to align 没有可对齐的内容 +@msg select a clip group 请选择一个剪切组 +@msg select two or more objects to distribute 请选择两个或更多要分布的对象 +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis 必须是 "vertical"、"horizontal" 或以度为单位的角度,而不是 `{v}` +@msg give horizontal or vertical 请给出 horizontal 或 vertical +@msg selection has no bounds 所选对象没有边界 +@msg the top object must be a path, compound path or text object to use as a clipping mask 最上层对象必须是路径、复合路径或文本对象才能用作剪切蒙版 +@msg no target object has an opacity mask: select one or give `id` 没有目标对象带有不透明度蒙版:请选择一个,或给出 `id` +@msg the object already has an opacity mask 该对象已有不透明度蒙版 +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` 是印刷色:色调仅适用于全局色和专色 +@msg `{name}` isn't a colour swatch `{name}` 不是颜色色板 +@msg a document needs at least one artboard 文档至少需要一个画板 +@msg empty path 路径为空 +@msg join needs open paths 连接需要开放路径 +@msg path is already closed 路径已闭合 +@msg select paths to join 请选择要连接的路径 +@msg direct-select anchor points to cut at (not the end points of open paths) 请直接选择要在其处切割的锚点 (不能是开放路径的端点) +@msg select paths or anchor points 请选择路径或锚点 +@msg Divide Objects Below: select a path 分割下方对象:请选择一条路径 +@msg Divide Objects Below: select exactly one path 分割下方对象:请只选择一条路径 +@msg Offset Path: select paths 偏移路径:请选择路径 +@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) 偏移路径:偏移后所选路径不剩任何部分 (内缩量大于形状) +@msg Outline Stroke: no stroked paths selected 轮廓化描边:未选择带描边的路径 +@msg Split Into Grid: select paths large enough for the grid 分割为网格:请选择足够大、可容纳网格的路径 +@msg empty cutter 切割对象为空 +@msg paths contain invalid coordinates 路径包含无效坐标 +@msg {label} produced an empty result {label} 生成了空结果 +@msg {label}: lost track of a shape's style {label}:丢失了某个形状的样式 +@msg {label}: select paths {label}:请选择路径 +@msg {label}: select paths, compound paths, groups or text {label}:请选择路径、复合路径、编组或文字 +@msg already editing a pattern 已在编辑图案 +@msg no pattern `{name}` 没有图案 `{name}` +@msg not in pattern editing mode 不在图案编辑模式中 +@msg pattern missing 缺少图案 +@msg select a repeat 请选择一个重复对象 +@msg select an Intertwine group 请选择一个交织组 +@msg select the art to repeat 请选择要重复的图稿 +@msg the document has no patterns 文档中没有图案 +@msg no artboard 没有画板 +@msg installing from a path is not available on the web; pass the module as `dataBase64` 在网页版中无法从路径安装;请以 `dataBase64` 传入模块 +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` 必须是 `auto` 或语言代码,例如 `en` 或 `zh-hant` +@msg `{key}` must be a #rrggbb colour `{key}` 必须是 #rrggbb 颜色 +@msg `{key}` must be a string `{key}` 必须是字符串 +@msg `{key}` must be a whole number `{key}` 必须是整数 +@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` 必须是介于 {min} 和 {max} 之间的整数 (实际为 {x}) +@msg `{key}` must be between {min} and {max} (got {x}) `{key}` 必须介于 {min} 和 {max} 之间 (实际为 {x}) +@msg `{key}` must be one of {_1} `{key}` 必须是 {_1} 之一 +@msg unknown preference `{key}` 未知首选项 `{key}` +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 数据恢复没有可保存副本的位置:请设置“首选项 → 文件处理 → 数据恢复 → 文件夹” (recoveryFolder) +@msg `{area}` is no recovery area name `{area}` 不是恢复区域名称 +@msg `{name}` is no recovery entry name `{name}` 不是恢复条目名称 +@msg no clock to keep a heartbeat by 没有可用于维持心跳的时钟 +@msg no free recovery area 没有空闲的恢复区域 +@msg no native format 没有原生格式 +@msg nothing encoded 没有编码任何内容 +@msg recovery copy `{file}`: {e} 恢复副本 `{file}`:{e} +@msg {name}: no such recovery entry {name}:没有此恢复条目 +@msg Combine Slices: the slices have no bounds 组合切片:切片没有边界 +@msg Create from Guides: the document has no artboard 从参考线创建:文档没有画板 +@msg Create from Guides: the guides make more than {MAX_PIECES} slices 从参考线创建:参考线将生成超过 {MAX_PIECES} 个切片 +@msg Create from Selection: the selection has no area 从选区创建:所选对象没有面积 +@msg Release: no layer to put the rectangle in 释放:没有可放置矩形的图层 +@msg a slice size must be a positive number of points 切片尺寸必须是正数 (以点为单位) +@msg divide into 1 to {MAX_PIECES} slices 请划分为 1 到 {MAX_PIECES} 个切片 +@msg no slice selected 未选择切片 +@msg the document has no slices 文档中没有切片 +@msg no saved profiles 没有已保存的配置文件 +@msg select a stroke with a variable width 请选择一个宽度可变的描边 +@msg the selected stroke's profile is already listed as {_1} 所选描边的配置文件已列为 {_1} +@msg a graphic style named `{to}` already exists 名为 `{to}` 的图形样式已存在 +@msg no graphic style `{name}` 没有图形样式 `{name}` +@msg node {_1} is not text 节点 {_1} 不是文字 +@msg Text Wrap: select a wrap object 文本绕排:请选择一个绕排对象 +@msg layers can't be wrap objects 图层不能作为绕排对象 +@msg no threaded text selected 未选择串接文本 +@msg Create Outlines: select text objects 创建轮廓:请选择文本对象 +@msg paste unavailable 无法粘贴 +@msg Save for Web: the image is empty 存储为 Web 所用格式:图像为空 +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}:`{h}` 不是颜色,例如 "#ff8800" +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}:最多 {MAX_TABLE} 种颜色 +@msg no artboard {i} (0-based; the document has {_1}) 没有画板 {i} (从 0 开始计数;文档有 {_1} 个) +@msg no selected slice in the image: select slices, or save all of them 图像中没有选中的切片:请选择切片,或全部保存 +@msg nothing to save: the document has no visible art 没有可保存的内容:文档中没有可见的图稿 +@msg percent must be above 0 and at most 6400, not {p} 百分比必须大于 0 且不超过 6400,而不是 {p} +@msg unknown preset `{name}` (see webExport.presets.list) 未知预设 `{name}` (请参阅 webExport.presets.list) +@msg width and height are at least 1 pixel 宽度和高度至少为 1 像素 +@msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必须是 1、3 或 5 +@msg `{path}` must be a bool or an object of bools `{path}` 必须是布尔值或由布尔值组成的对象 +@msg cannot distort a zero-size bounding box 无法扭曲尺寸为零的定界框 +@msg nothing to distort 没有可扭曲的内容 +@msg unknown option `{path}.{k}` 未知选项 `{path}.{k}` +@msg command `{0}` is not available right now: {1} 命令 `{0}` 当前不可用:{1} +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 中出现内部错误:{msg} (文档保持原样;请报告此错误) +@msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的参数无效:{msg} +@msg no active document 没有活动文档 +@msg no interaction in progress 没有正在进行的交互 +@msg no such object {0} 没有对象 {0} +@msg result would exceed the canvas (coordinates out of range) 结果将超出画布 (坐标超出范围) +@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 `{what}` closes nothing `{what}` 没有可结束的内容 +@msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容 +@msg a path has too many points 某条路径的点过多 +@msg it ends inside a layer 它在图层内部结束 +@msg it has art outside its layers 它有位于图层之外的图稿 +@msg it has more than {MAX_NODES} objects 它包含超过 {MAX_NODES} 个对象 +@msg it has no layers 它没有图层 +@msg it has too many values in a row 它的一行中值过多 +@msg it has {_1}, which VectorCraft doesn't read from it yet 它包含 {_1},VectorCraft 尚不能从中读取 +@msg its objects are nested too deeply 其对象嵌套过深 +@msg its section ending with `%{end}` doesn't end 其以 `%{end}` 结束的部分没有结束 +@msg an image VectorCraft can't read was left out 已略去 VectorCraft 无法读取的图像 +@msg an image whose data is cut short was left out 已略去数据不完整的图像 +@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替 +@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 +@msg patterns nested too deeply are filled with mid-grey 嵌套过深的图案以中灰色填充 +@msg shadings of an unknown type were left out 已略去未知类型的着色 +@msg shadings whose functions are of an unknown type are filled with their middle colour 函数类型未知的着色以其中间颜色填充 +@msg the file's preview header points outside the file 文件的预览头指向文件之外 +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it 无法读取此 PostScript 文件 ({why}):请在创建它的应用中将其保存为 PDF 或 SVG +@msg this is not a PostScript file 这不是 PostScript 文件 +@msg a PostScript error ({name} in `{op}`) PostScript 错误 ({name},位于 `{op}`) +@msg a PostScript error ({name} in `{op}`), in {procs} PostScript 错误 ({name},位于 `{op}`),在 {procs} 中 +@msg a PostScript error ({name} in `{op}`: {at}) PostScript 错误 ({name},位于 `{op}`:{at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript 错误 ({name},位于 `{op}`:{at}),在 {procs} 中 +@msg a PostScript error ({name}) PostScript 错误 ({name}) +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know 它使用了 `{at}`,VectorCraft 的 PostScript 读取器无法识别 +@msg the file was read up to an error ({why}): what follows it was left out 文件已读取到出错处 ({why}):其后的内容已略去 +@msg this file's PostScript couldn't be read ({why}): {what} 无法读取此文件的 PostScript ({why}):{what} +@msg it has no page 它没有页面 +@msg it has too many text objects 它的文本对象过多 +@msg it has too much text 它的文字过多 +@msg its editing data doesn't say where its art is 其编辑数据未说明其图稿的位置 +@msg its editing data doesn't say where its artboard is 其编辑数据未说明其画板的位置 +@msg its editing data is for a different page 其编辑数据属于另一个页面 +@msg its page has text, and its layers have no text objects to put it in 其页面中有文字,但其图层中没有可放置这些文字的文本对象 +@msg the art on them differs from the page's in {_1}% of it 其上的图稿与页面的图稿有 {_1}% 不同 +@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same 其上的图稿与页面的图稿有 {_1}% 不同:它们仍从其编辑数据导入 +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer 未读取文件的图层 ({_1}):文件以其页面导入,位于一个图层中 +@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part 未从文件的编辑数据中读取图层 ({_1}):图层来自其 PDF 部分 +@msg its Windows metafile preview was opened instead, which may simplify the art 已改为打开其 Windows 图元文件预览,图稿可能会被简化 +@msg its preview was placed instead, at screen resolution 已改为以屏幕分辨率置入其预览 +@msg shadings of more than 4096 patches or triangles were left out 已略去超过 4096 个面片或三角形的着色 +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} 它在 {procs} 中使用了 `{at}`,VectorCraft 的 PostScript 读取器无法识别 +@msg the export region is not finite 导出区域不是有限值 +@msg a page out of range 页面超出范围 +@msg a page's placement is not finite 页面的位置不是有限值 +@msg nothing to print 没有可打印的内容 +@msg page size {w} × {h} 页面尺寸 {w} × {h} +@msg the preview image is empty 预览图像为空 +@msg the thumbnail image is malformed 缩览图格式错误 +@msg can't encode the document: {0} 无法编码文档:{0} +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} 无法保存版本 {0}:VectorCraft 写入的版本为 {MIN_VERSION} 到 {VERSION} +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) 文件版本 {0} 比此 VectorCraft 支持的版本 ({VERSION}) 更新 +@msg invalid image data for `{0}` `{0}` 的图像数据无效 +@msg not a VectorCraft file: {0} 不是 VectorCraft 文件:{0} +@msg the compressed file unpacks to more than {_1} MB 压缩文件解压后超过 {_1} MB +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files 版本 {0} 的文件不能压缩:只有版本 {COMPRESSED_SINCE} 及更高版本能打开压缩文件 +@msg a bitmap is empty 某个位图为空 +@msg a bitmap is too large to read 某个位图过大,无法读取 +@msg compressed (RLE) bitmaps can't be read yet 尚不能读取压缩 (RLE) 位图 +@msg the image's pixels don't match its size 图像的像素与其尺寸不符 +@msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图 +@msg not an EMF or WMF file 不是 EMF 或 WMF 文件 +@msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取 +@msg the EMF header is damaged EMF 文件头已损坏 +@msg damaged 已损坏 +@msg the WMF header is damaged WMF 文件头已损坏 +@msg the export region is empty 导出区域为空 +@msg a stream runs past the end 某个流超出了结尾 +@msg a stream's length is wrong 某个流的长度错误 +@msg a stream's length isn't a number 某个流的长度不是数字 +@msg bad stream length 流长度错误 +@msg PDF writer error: {0} PDF 写入器错误:{0} +@msg artboard {0} does not exist 画板 {0} 不存在 +@msg cannot read PDF: {0} 无法读取 PDF:{0} +@msg invalid PDF setting: {0} PDF 设置无效:{0} +@msg page {0} does not exist (the PDF has {1}) 页面 {0} 不存在 (该 PDF 有 {1} 页) +@msg the PDF has no pages 该 PDF 没有页面 +@msg the PDF is password-protected: give its password 该 PDF 受密码保护:请提供其密码 +@msg the PDF password is wrong PDF 密码错误 +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG 该文件只包含占位页面,不包含其图稿 (保存时未启用 PDF 兼容性):请在启用 PDF 兼容性后重新保存,或保存为 PDF 或 SVG +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS 这是 PostScript 文件 (以旧格式或未启用 PDF 兼容性保存的 .ai,或 EPS 文件),不是 PDF:“文件 › 打开”和“文件 › 置入”会将其作为 EPS 读取 +@msg unsupported: {0} 不支持:{0} +@msg a page is missing 缺少某个页面 +@msg a page tree kid isn't a reference 某个页面树子节点不是引用 +@msg a page tree node has no kids 某个页面树节点没有子节点 +@msg an object can't be read 无法读取某个对象 +@msg bad page tree 页面树错误 +@msg bad trailer 文件尾错误 +@msg no catalog 没有目录 +@msg no cross-reference table 没有交叉引用表 +@msg no pages 没有页面 +@msg no plug-in {0} is installed 未安装增效工具 {0} +@msg not a valid plug-in module: {0} 不是有效的增效工具模块:{0} +@msg plug-in ABI: {0} 增效工具 ABI:{0} +@msg plug-in exceeded its {0} 增效工具超出了其 {0} +@msg plug-in failed: {0} 增效工具失败:{0} +@msg plug-in manifest: {0} 增效工具清单:{0} +@msg plug-in output: {0} 增效工具输出:{0} +@msg plug-in parameters: {0} 增效工具参数:{0} +@msg plug-in trapped: {0} 增效工具运行时出错 (trap):{0} +@msg is not an image VectorCraft reads 不是 VectorCraft 可读取的图像 +@msg not found 未找到 +@msg SVG parse error: {0} SVG 解析错误:{0} +@msg decimals must be {_1}–{_2} (got {_3}) 小数位数必须为 {_1}–{_2} (实际为 {_3}) +@msg Exporting {_1} 正在导出 {_1} +@msg Exported {what} 已导出 {what} +@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是颜色:请写为 #rrggbb +@msg could not decode image: {0} 无法解码图像:{0} +@msg image is empty 图像为空 +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) 图像为 {width} × {height} 像素,超出图像描摹的上限 ({max} 百万像素) +@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead 这不是纯色标志 (其实色区域中只有 {_1}% 与少数几种纯色相符),因此“纯色标志”模式会把其中的渐变变成色块:请改用“颜色”模式 +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset 此描摹将生成超过 {max} 个锚点,超出文档可流畅显示的数量:请提高“杂色”,降低“路径”或“颜色”,或选择保真度较低的预设 +@msg internal error: {msg} (please report this bug) 内部错误:{msg} (请报告此错误) +@msg the background worker stopped 后台工作进程已停止 +@msg {_1} failed: {e} {_1} 失败:{e} +@msg {label}… {label}… +@msg no blend options 没有混合选项 +@msg bad color {v} 颜色 {v} 无效 +@msg the Color Picker is not open 拾色器未打开 +@msg the screen color sampler is already open 屏幕取色器已打开 +@msg this system has no screen color sampler 此系统没有屏幕取色器 +@msg Corners edits the corners of a path 转角用于编辑路径的转角 +@msg give corners of the path: anchors where its sides meet at an angle 请给出路径的转角:其各边以一定角度相交处的锚点 +@msg select one path, or give its `id` 请选择一条路径,或给出其 `id` +@msg the drawing is no longer loaded 该图纸已不再加载 +@msg No saved selections (Select → Save Selection…) 没有已保存的选区 (选择 → 保存选区…) +@msg effect {k} is `{_1}`, not `{id}` 效果 {k} 是 `{_1}`,而不是 `{id}` +@msg no effect at index {k} 索引 {k} 处没有效果 +@msg unknown effect `{id}` 未知效果 `{id}` +@msg `{id}` has no envelope dialog `{id}` 没有封套对话框 +@msg select an envelope 请选择一个封套 +@msg no format to export to 没有可导出的格式 +@msg nothing to export: the document has no visible art (or turn on Use Artboards) 没有可导出的内容:文档中没有可见的图稿 (或请打开“使用画板”) +@msg add a format to export 请添加要导出的格式 +@msg check an asset to export 请勾选要导出的资源 +@msg `{tool}` isn't a freehand tool `{tool}` 不是手绘工具 +@msg bad colour `{hex}` (#rrggbb) 颜色 `{hex}` 无效 (#rrggbb) +@msg no gradient stop or freeform point is selected 未选择渐变色标或任意形状点 +@msg no graphic style `{n}` 没有图形样式 `{n}` +@msg select two or more graphic styles to merge 请选择两个或更多要合并的图形样式 +@msg the PDF is no longer loaded 该 PDF 已不再加载 +@msg no such layer or object 没有此图层或对象 +@msg `{tool}` isn't a Liquify tool `{tool}` 不是液化工具 +@msg {_1} linked file(s) can't be read here: their previews show 此处无法读取 {_1} 个链接文件:显示的是其预览 +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions 有 {_1} 个链接图像已在磁盘上更改:“更新链接”可显示新版本 +@msg pick a colour, gradient or pattern to save as a swatch 请选取要保存为色板的颜色、渐变或图案 +@msg Packaged {n} file(s) in {folder}{note} 已将 {n} 个文件打包到 {folder}{note} +@msg Packaged {n} file(s){note} 已打包 {n} 个文件{note} +@msg choose a location for the package 请为打包选择位置 +@msg missing `folder` 缺少 `folder` +@msg the package came back damaged 返回的打包文件已损坏 +@msg no preset editor 没有预设编辑器 +@msg no perspective grid preset named `{name}` 没有名为 `{name}` 的透视网格预设 +@msg no active perspective plane 没有活动的透视平面 +@msg plane must be left, right or ground plane 必须是 left、right 或 ground +@msg missing `id` (see plugin.list) 缺少 `id` (请参阅 plugin.list) +@msg no plug-in {id} is installed 未安装增效工具 {id} +@msg {_1} is a live effect (Effect › Plug-ins) {_1} 是实时效果 (效果 › 增效工具) +@msg a preset named `{_1}` exists 名为 `{_1}` 的预设已存在 +@msg no print preset named `{base}` 没有名为 `{base}` 的打印预设 +@msg no print preset named `{name}` 没有名为 `{name}` 的打印预设 +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) 没有名为 `{name}` 的已保存打印预设 ({DEFAULT_PRESET} 受保护:请以它为基础新建一个) +@msg print settings are not an object 打印设置不是对象 +@msg no colour group `{g}` 没有颜色组 `{g}` +@msg select artwork (or a colour group) to recolor 请选择要重新着色的图稿 (或颜色组) +@msg Downloaded {_1} file(s) 已下载 {_1} 个文件 +@msg Preview in Browser needs the desktop app (the web downloads files instead) “在浏览器中预览”需要桌面应用 (网页版会改为下载文件) +@msg no file system here 此处没有文件系统 +@msg nothing to preview 没有可预览的内容 +@msg Saved {n} files beside {path} 已在 {path} 旁保存 {n} 个文件 +@msg `{_1}` is no save command `{_1}` 不是保存命令 +@msg name the file 请为文件命名 +@msg unknown format 未知格式 +@msg PDF settings are not an object PDF 设置不是对象 +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) 没有名为 `{name}` 的已保存 PDF 预设 (内置预设为只读:请以它们为基础新建一个) +@msg Saved to User Defined: {_1} 已保存到“用户定义”:{_1} +@msg choose a direction to divide in 请选择分割方向 +@msg choice must be "merge" or "add", not `{choice}` choice 必须是 "merge" 或 "add",而不是 `{choice}` +@msg `{name}` has no options `{name}` 没有选项 +@msg no swatch `{name}` 没有色板 `{name}` +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` 不是颜色 (#rrggbb 或预设名称) +@msg give the path `id` and the width point `index` 请给出路径的 `id` 和宽度点的 `index` +@msg no such width point 没有此宽度点 +@msg onto must name a panel floating in another group onto 必须指定浮动在另一个组中的面板 +@msg below and above must name a panel floating in another group below 和 above 必须指定浮动在另一个组中的面板 +@msg give only one of onto, below and above onto、below 和 above 只能给出一个 +@msg column must be true or a number from 1 column 必须是 true 或从 1 开始的数字 +@msg unknown panel `{raw}` 未知面板 `{raw}` +@msg x and y must both be numbers x 和 y 都必须是数字 +@msg Downloaded {what} 已下载 {what} +@msg Exported {count} file(s) to {folder} 已将 {count} 个文件导出到 {folder} +@msg Imported {what}: {_1} 已导入 {what}:{_1} +@msg Installed plug-in {_1} 已安装增效工具 {_1} +@msg Opened with {_1} note(s): {_2} 已打开,有 {_1} 条注释:{_2} +@msg Saved {path} 已保存 {path} +@msg no app to open files here 此处没有可打开文件的应用 +@msg no data 没有数据 +@msg no document 没有文档 +@msg no file manager here 此处没有文件管理器 +@msg no revert command 没有恢复命令 +@msg the Save PDF dialog didn't open “保存 PDF”对话框未打开 +@msg the document has never been saved 该文档从未保存过 +@msg the export returned unreadable data 导出返回了无法读取的数据 +@msg {status} ({e}) {status} ({e}) +@msg Exported {what} with {_1} note(s): {first} 已导出 {what},有 {_1} 条注释:{first} +@msg Saved {path} with {_1} note(s): {first} 已保存 {path},有 {_1} 条注释:{first} +@msg Couldn't open {name}: {e} 无法打开 {name}:{e} +@msg Couldn't read {name}: {e} 无法读取 {name}:{e} +@msg Internal error: {msg} (please report this bug) 内部错误:{msg} (请报告此错误) +@msg Opened {url} 已打开 {url} +@msg No saved views (View → New View…) 没有已保存的视图 (视图 → 新建视图…) +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` 必须是其菜单项会打开对话框的命令 (请参阅 ui.menuDialog) +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` 必须是 grayscale、rgb、hsb、cmyk 或 websafe +@msg `{key}` must be true or false `{key}` 必须是 true 或 false +@msg brightness must be dark|mediumDark|mediumLight|light brightness 必须是 dark|mediumDark|mediumLight|light +@msg index must be a number or first, previous, next or last index 必须是数字,或 first、previous、next 或 last +@msg index must be a number or first, previous, next or last, not `{other}` index 必须是数字,或 first、previous、next 或 last,而不是 `{other}` +@msg lang must be one of {_1} lang 必须是 {_1} 之一 +@msg missing `name` 缺少 `name` +@msg missing `tool` 缺少 `tool` +@msg missing index 缺少 index +@msg multiple windows land with M11.5 多窗口将在 M11.5 中提供 +@msg no PNG encoder 没有 PNG 编码器 +@msg no document open 没有打开的文档 +@msg no effect applied yet 尚未应用任何效果 +@msg no such perspective grid preset 没有此透视网格预设 +@msg no such recent file 没有此最近使用的文件 +@msg no such recent font 没有此最近使用的字体 +@msg no such saved selection 没有此已保存的选区 +@msg no such user library 没有此用户库 +@msg no such view 没有此视图 +@msg printing isn't available here 此处无法打印 +@msg select an image 请选择一个图像 +@msg the document has no artboards 文档没有画板 +@msg tool (a tool id) is required 需要 tool (工具 id) +@msg unknown tool `{t}` 未知工具 `{t}` +@msg no such action 没有此动作 +@msg choose a folder to export to 请选择要导出到的文件夹 +@msg the selection has no URL (or they differ) 所选对象没有 URL (或其 URL 各不相同) +@msg select some type first 请先选择一些文字 +@msg {_1} font families available 有 {_1} 个可用的字体系列 +@msg {_1} has no {_2} style {_1} 没有 {_2} 样式 +@msg Copied the CSS of {rules} object(s) 已复制 {rules} 个对象的 CSS +@msg Copied the script report 已复制脚本报告 +@msg Exported {path} 已导出 {path} +@msg no CSS to copy: select objects with CSS of their own 没有可复制的 CSS:请选择带有自身 CSS 的对象 +@msg unknown highlight `{h}` 未知的高亮显示 `{h}` +@msg unknown overprints `{o}` (preserve, simulate or discard) 未知的叠印方式 `{o}` (preserve、simulate 或 discard) +@msg unknown preset `{name}` (see flattener.presets.list) 未知预设 `{name}` (请参阅 flattener.presets.list) +@msg Select text or click into text with the Type tool to insert glyphs 请选择文字,或用文字工具在文字中单击,以插入字形 +@msg `library` is a library id or name (see {list}) `library` 是库 id 或名称 (请参阅 {list}) +@msg no library `{key}` (see {list}) 没有库 `{key}` (请参阅 {list}) +@msg select a linked image: an embedded one has no file 请选择链接的图像:嵌入的图像没有文件 +@msg {path} can't be found: relink it first 找不到 {path}:请先重新链接 +@msg Embedded {n} image(s); {missing} missing: relink them first 已嵌入 {n} 张图像;缺少 {missing} 张:请先重新链接 +@msg Relinked {_1} image(s) 已重新链接 {_1} 张图像 +@msg Relinked {n} image(s) 已重新链接 {n} 张图像 +@msg Relinked {n} image(s); not in that folder: {_1} 已重新链接 {n} 张图像;不在该文件夹中:{_1} +@msg Unembedded to {_1} 已取消嵌入到 {_1} +@msg Updated {_1} image(s) 已更新 {_1} 张图像 +@msg no swatch library `{l}` (see swatch.library.list) 没有色板库 `{l}` (请参阅 swatch.library.list) +@msg no user library folder here 此处没有用户库文件夹 +@msg Couldn't place {name}: the document it was dropped on was closed 无法置入 {name}:将它拖放到的文档已关闭 +@msg no files to place 没有要置入的文件 +@msg paths: give at least one file paths:请至少给出一个文件 +@msg Couldn't place {name}: {e} 无法置入 {name}:{e} +@msg no print service 没有打印服务 +@msg the print job has no data 打印作业没有数据 +@msg there are no printer settings to open here 此处没有可打开的打印机设置 +@msg Couldn't save recovery data: {e} 无法保存恢复数据:{e} +@msg Saved recovery data for {title} 已保存 {title} 的恢复数据 +@msg Discarded {_1} recovered document(s) 已丢弃 {_1} 个恢复的文档 +@msg Restored {_1} recovered document(s): save them to keep them 已还原 {_1} 个恢复的文档:请保存以保留它们 +@msg Saving recovery data for {_1} 正在保存 {_1} 的恢复数据 +@msg This shortcut file is in a form VectorCraft can't read. 此快捷键文件的格式 VectorCraft 无法读取。 +@msg bad shortcut 快捷键无效 +@msg can't parse shortcut `{c}` 无法解析快捷键 `{c}` +@msg can't parse shortcut `{s}` for `{k}` 无法解析 `{k}` 的快捷键 `{s}` +@msg no dialog open 没有打开的对话框 +@msg no file reader 没有文件读取器 +@msg no file writer 没有文件写入器 +@msg not a VectorCraft shortcut set (missing `overrides`) 不是 VectorCraft 快捷键组 (缺少 `overrides`) +@msg not a shortcut set: the text doesn't start with a slash name and an opening brace 不是快捷键组:文本不是以斜杠名称和左花括号开头 +@msg shortcut for `{k}` must be a string `{k}` 的快捷键必须是字符串 +@msg tool shortcuts must be a single key (optionally with Shift) 工具快捷键必须是单个键 (可带 Shift) +@msg unknown preset (one of {_1}) 未知预设 (应为 {_1} 之一) +@msg unknown tool or command `{key}` 未知工具或命令 `{key}` +@msg {_1} is already used by {_2} {_1} 已被 {_2} 使用 +@msg Couldn't copy to the system clipboard: {e} 无法复制到系统剪贴板:{e} +@msg Couldn't paste from the clipboard: {e} 无法从剪贴板粘贴:{e} +@msg nothing selected 未选择任何对象 +@msg the {tool} tool has no options {tool} 工具没有选项 +@msg unknown tool `{tool}` 未知工具 `{tool}` +@msg {tool} isn't in a toolbar group of several tools {tool} 不在包含多个工具的工具栏组中 +@msg no such document 没有此文档 +@msg {_1}: not saved, so not closed {_1}:未保存,因此未关闭 +@msg `{name}` is a built-in workspace; choose another name `{name}` 是内置工作区;请选择其他名称 +@msg `{to}` is not an available name `{to}` 不是可用的名称 +@msg no user workspace `{from}` 没有用户工作区 `{from}` +@msg unknown workspace (one of {_1}) 未知工作区 (应为 {_1} 之一) +@msg workspace name can't be empty 工作区名称不能为空 +@msg No fonts are missing in this document 此文档中没有缺失的字体 +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft 不会搜索其他应用或系统的文件夹:{path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft 不会搜索“用户”中的 Shared 或 Public 文件夹:{path} +@msg {path} is not a folder {path} 不是文件夹 +@msg the search couldn't start: try again in a moment 无法开始搜索:请稍后重试 +@msg searching a folder isn't available on the web 网页版无法搜索文件夹 +@msg every search thread stopped on an internal error (please report this bug) 所有搜索线程均因内部错误而停止 (请报告此错误) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it 此处未设置 VectorCraft 的字体文件夹:桌面应用和 vectorcraft-cli 会设置该文件夹 +@msg choose the font files to add 请选择要添加的字体文件 +@msg {n} font file(s) are now in {folder} {n} 个字体文件现已位于 {folder} +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} 个字体文件现已位于 {folder};有 {failed} 个无法添加:{reason} +@msg {n} font(s) aren't available here: their text shows in another font 此处没有 {n} 种字体:使用它们的文字以其他字体显示 +@msg not a file 不是文件 +@msg not a font file 不是字体文件 +@msg that document is no longer open 该文档已不再打开 +@msg larger than 256 MB 大于 256 MB +@msg the files come to more than 1 GB: add fewer at a time 文件总计超过 1 GB:请每次少添加一些 +@msg every numbered name for it is taken in the Fonts folder 字体文件夹中所有带编号的名称均已被占用 +@msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 +@msg No linked files to look for 没有要查找的链接文件 +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} 已重新链接 {n} 张图像;无法重新链接 {name}:{reason} +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 +@msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} +@msg `corners` must be an array of anchor indices, not {v} `corners` 必须是由锚点索引组成的数组,而不是 {v} +@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步骤 {i} 的结果中没有此值 (要表示 $ 本身,请写 $$) +@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步骤之前没有步骤 {i} (步骤从 0 开始计数;要表示 $ 本身,请写 $$) +@msg object ids are non-negative integers, not {v} 对象 id 是非负整数,而不是 {v} +@msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path} +@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path} +@msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path} +@msg automation path rejected: outside the write root {root}: {path} 已拒绝自动化路径:位于写入根目录 {root} 之外:{path} +@msg automation path rejected: path is empty 已拒绝自动化路径:路径为空 +@msg automation path rejected: a link that leads nowhere: {path} 已拒绝自动化路径:链接指向不存在的位置:{path} +@msg automation path rejected: cannot resolve {path}: {e} 已拒绝自动化路径:无法解析 {path}:{e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} 已拒绝自动化路径:在不存在的文件夹下向上级遍历:{path} +@msg automation path rejected: reserved device names are not allowed: {path} 已拒绝自动化路径:不允许使用保留的设备名称:{path} +@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode 无法读取多通道 PSD 文件:请以 RGB、CMYK 或灰度模式保存 +@msg not a PSD file 不是 PSD 文件 +@msg the PSD file has an unknown colour mode ({m}) PSD 文件的颜色模式未知 ({m}) +@msg the PSD file's image data uses an unknown compression ({m}) PSD 文件的图像数据使用了未知的压缩方式 ({m}) +@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) +@msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 +@msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 +@msg the PSD file is cut short PSD 文件不完整 +@msg the PSD file's header is damaged PSD 文件头已损坏 +@msg the PSD file's image data can't be read: {e} 无法读取 PSD 文件的图像数据:{e} +@msg Imported library {name} 已导入库 {name} +@msg invalid restored panel group 还原的面板组无效 +@msg Invalid panel layout; restored the default dock 面板布局无效;已还原默认停靠 +@msg action is required 需要 action +@msg anchor must name a registered panel anchor 必须指定已注册的面板 +@msg before must name a panel before 必须指定面板 +@msg docking action contains an unknown panel or an excessive split path 停靠操作包含未知面板或过长的拆分路径 +@msg group must be true or false group 必须是 true 或 false +@msg move requires a destination panel 移动需要目标面板 +@msg move requires an existing destination panel 移动需要现有的目标面板 +@msg onto must name a floating panel onto 必须指定浮动面板 +@msg panel group is empty 面板组为空 +@msg panel must name a registered panel panel 必须指定已注册的面板 +@msg saved panel layout is invalid; reset the workspace first 保存的面板布局无效;请先重置工作区 +@msg x and y must be provided together x 和 y 必须同时提供 +@msg zone must be a string zone 必须是字符串 +@msg zone must be tab, center, left, right, top or bottom zone 必须是 tab、center、left、right、top 或 bottom +@msg {key} must be a finite coordinate {key} 必须是有限坐标 +@msg invalid native panel set 原生面板集无效 +@msg missing panel layout 缺少面板布局 +@msg unsupported native docking operation 不支持的原生停靠操作 +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) 压缩文件解压后超出 VectorCraft 的读取上限 (文档 {_1} MB,每张图像 {_2} MB) +@msg the document is too large to save compressed ({0}): save it with Use Compression off 文档过大,无法压缩保存 ({0}):请关闭“使用压缩”后保存 +@msg the file stopped being read (please report this bug) 文件读取已中断 (请报告此错误) +@msg the image can't be read as a texture 无法将图像作为纹理读取 +@msg give a fraction `t` from 0 to 1 请给出 0 到 1 之间的比例 `t` +@msg give the path `id` 请给出路径的 `id` +@msg invalid stroke widths 描边宽度无效 +@msg object has no weighted stroke 对象没有带粗细的描边 +@msg Angles require two straight segments 角度需要两条直线段 +@msg Choose two different points 请选择两个不同的点 +@msg Choose two non-parallel straight segments 请选择两条不平行的直线段 +@msg Choose two nonzero angle arms 请选择两条长度不为零的角边 +@msg Dimension point is outside the canvas 尺寸标注点位于画布之外 +@msg Dimension size is outside its supported range 尺寸标注大小超出支持的范围 +@msg First measured segment no longer exists 第一条测量线段已不存在 +@msg Invalid dimension distance 尺寸标注距离无效 +@msg Invalid dimension font name 尺寸标注字体名称无效 +@msg Invalid drawing scale 图纸比例无效 +@msg Invalid real scale 实际比例无效 +@msg Measured anchor no longer exists 测量锚点已不存在 +@msg Measured segment no longer exists 测量线段已不存在 +@msg Non-finite dimension 尺寸标注不是有限值 +@msg Scale must be drawing:real, for example 1:10 比例必须为 图纸:实际,例如 1:10 +@msg Scale ratio must be between 1:1000000 and 1000000:1 比例必须介于 1:1000000 和 1000000:1 之间 +@msg Second measured segment no longer exists 第二条测量线段已不存在 +@msg Unknown dimension mode 未知的尺寸标注模式 +@msg Unknown dimension option {k} 未知的尺寸标注选项 {k} +@msg span must be "object" or "selection", got {v} span 必须是 "object" 或 "selection",实际为 {v} +@msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而不是色板 +@msg span: "selection" places a gradient: give `gradient` span: "selection" 放置渐变:请给出 `gradient` +@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" 放置线性或径向渐变,而不是任意形状渐变 +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" 自行放置渐变:请省略 `start` 和 `end` (它们已按文档坐标放置渐变) +@msg Batch: {count} processed 批处理:已处理 {count} 个 +@msg Batch: {count} processed, {failed} failed 批处理:已处理 {count} 个,失败 {failed} 个 +@msg choose a destination folder 请选择目标文件夹 +@msg choose a source folder 请选择源文件夹 +@msg open a document with data sets 请打开包含数据集的文档 +@msg the action has no steps 该动作没有步骤 # Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands. Import Shortcut Set 导入快捷键组 Import Shortcut Set… 导入快捷键组… diff --git a/docs/development.md b/docs/development.md index 7627cbfb2..094f6f692 100644 --- a/docs/development.md +++ b/docs/development.md @@ -233,7 +233,7 @@ other `sv-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian control channel, MCP and tests read them) and are translated only where the status bar draws them (`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as `Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the - placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Spanish, French, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Vietnamese and Finnish cover every + placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Swedish, Ukrainian, Vietnamese and Finnish cover every message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in `COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs a row in each of those catalogs (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui complete_languages_translate_every_message` lists them all). Other languages show messages in English diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d44b53f02..e632aef62 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -24,7 +24,7 @@ Chinese by `imachus`). None is recorded as reviewed by a native speaker; the cat | Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) | |---|---|---|---|---|---|---|---| | English | `en` | source, 100% | all | Latin | — | full | — | -| Simplified Chinese (Mandarin) | `zh-hans` | 3,647 rows; menus, panels, dialogs complete (tests); 85% overall | messages in English (1 of 604) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | partial | 2–3 | +| Simplified Chinese (Mandarin) | `zh-hans` | 4,761 rows, 714 of them `@msg` (counted 2026-10-16): menus, `tl!` literals and messages complete (tests) | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | | Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), with tests of conjuncts, reph, pre-base matras, nukta forms and caret stops that run when an OpenType Devanagari font is installed, but the egui interface does no complex shaping | no | none | 20–30 | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a3d40fa66..51e614bda 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -565,7 +565,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). 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. -- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; German, Spanish, French, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). +- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. From 8511d0321bee2434fcfb8e475b12b851a31c01bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 16:17:15 -0700 Subject: [PATCH 077/184] SVG export: justified and Mojikumi lines keep their letter spacing in live text SVG export wrote each style run of a line of live text as one and positioned the text again only after tabs and, on justified lines, after word spaces. A viewer then spaced the characters inside the with the font's own advances, so it lost the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line and the spacing that a Mojikumi Set changes. On justified lines, the textLength of horizontally scaled type also took in the justification space after it, so a viewer stretched the glyphs into that space. On a line that is justified or set with a Mojikumi Set, text_lines now lays the text out again as point type without the Mojikumi Set to get the advance a viewer gives each character. Each character that the layout draws away from where a viewer would draw it starts a new positioned , and textLength and line anchors use the viewer's widths. The last character of a horizontally scaled takes its width without the font's kerning against the next character. Other lines, and lines written with Fewer Elements, are written as before. Docs: docs/target-app-parity.md. --- crates/svg/src/export.rs | 118 ++++++++++++++++++--- crates/svg/tests/text_layout.rs | 178 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 284 insertions(+), 13 deletions(-) diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b51dffe43..b6e192410 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1941,7 +1941,7 @@ impl Writer<'_> { /// One line of laid-out text starting at `start`. Default: a positioned `` per segment /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with - /// the style changes nested in it, positioned again only after tabs and justified word spaces. + /// the style changes nested in it, positioned again only after tabs and inline graphics. /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. /// `positioned`: false when the caller already positioned the line (an outer ``). fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { @@ -1966,7 +1966,8 @@ impl Writer<'_> { attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); shift = st.baseline_shift; } - // After a tab or a justified word space the next segment is placed. + // After a tab, an inline graphic or a justified word space, and before a character that a + // reader would draw elsewhere, the next segment is placed. place = seg.brk || (!fewer && !base.anchored && positioned); if (st.h_scale - st.v_scale).abs() > 1e-9 { attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance))); @@ -2164,26 +2165,49 @@ struct Line { struct Segment { /// Index into `TextObject::runs`. run: usize, - /// Where the segment starts (text space; `y` is the baseline). + /// Where a reader starts drawing the segment (text space; `y` is the baseline). x: f64, y: f64, text: String, - /// The laid-out width (tracking and justification included). + /// The width a reader gives the segment (tracking included). On a line that is justified or + /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi + /// ([`reader_advances`]), for horizontally scaled type also without its last character's + /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. advance: f64, - /// The next segment needs its own position (after a tab, or a word space of a justified line). + /// The next segment needs its own position (after a tab, a word space of a justified line, or + /// the next segment starts with a character the layout draws elsewhere than a reader would). brk: bool, } +/// How far a reader may draw a character from where the layout draws it before the character is +/// given its own position (points). +const PLACE_TOLERANCE: f64 = 1e-6; + /// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, /// on justified lines, after each word space (unless `fewer`). The text comes from the clusters /// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all -/// caps are upper case. +/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also +/// starts a new segment at each character the layout draws elsewhere than a reader continuing +/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between +/// the characters of a justified Japanese line, and the spacing Mojikumi changes. fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { let plain = t.plain_text(); let mut lines = Vec::with_capacity(lay.lines.len()); + let (mut kerned, mut unkerned) = (None, None); for line in &lay.lines { let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); - let split_words = !fewer && !matches!(t.para_at(para).justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + let style = t.para_at(para); + let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. + let reader = if split_words || (!fewer && !style.mojikumi.is_none()) { + Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) + } else { + None + }; + // Where a reader draws the next character of the last segment, and the byte offset and + // reader's advance of the last segment's last cluster. + let mut pen = 0.0; + let mut tail = None; let mut segs: Vec = Vec::new(); let mut cluster = None; let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; @@ -2206,26 +2230,51 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - cluster = None; continue; } + // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). + let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); if g.len > 0 && cluster == Some(g.byte) { - // Another glyph of the same cluster. - if let Some(last) = segs.last_mut() { + // Another glyph of the same cluster (a reader's advance covers the whole cluster). + if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { last.advance += g.advance; + pen += g.advance; } continue; } cluster = Some(g.byte); + let (advance, x) = match set { + Some(&(advance, offset)) => { + // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. + let x = g.xf.translation().x - offset; + (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) + } + None => (g.advance, g.origin.x), + }; let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); let piece = if run.style.all_caps { src.to_uppercase() } else { src }; let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); let fresh = std::mem::take(&mut after_inline); + // The layout draws the character elsewhere than a reader continuing the line would. + let moved = reader.is_some() + && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); + pen = x + advance; match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh => { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved => { last.text.push_str(&piece); - last.advance += g.advance; + last.advance += advance; last.brk = brk; } - _ => segs.push(Segment { run: g.run, x: g.origin.x, y: g.origin.y, text: piece, advance: g.advance, brk }), + last => { + if let Some(last) = last { + last.brk |= moved; + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk }); + } } + tail = set.map(|_| (g.byte, advance)); + } + if let Some(last) = segs.last_mut() { + end_segment(last, tail, t, lay, &mut unkerned); } segs.retain(|s| !s.text.is_empty()); if !segs.is_empty() { @@ -2235,7 +2284,50 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - lines } -/// Where a line of segments starts: its first segment's origin or, `anchor`ed (0.5: centre, 1: +/// Ends segment `seg` of horizontally scaled type, which a reader stretches to its width +/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance +/// a reader gives it at the end of a ``, without the font's kerning against the character +/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. +fn end_segment( + seg: &mut Segment, + tail: Option<(usize, f64)>, + t: &TextObject, + lay: &vectorcraft_text::TextLayout, + unkerned: &mut Option>, +) { + let Some((byte, advance)) = tail else { return }; + if t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { + return; + } + if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { + seg.advance += alone - advance; + } +} + +/// The advance a reader of live SVG text gives each character cluster of `t` and how far from +/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out +/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by +/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual +/// kerning leaves it out). +fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { + let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; + for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { + p.mojikumi = vectorcraft_doc::Mojikumi::None; + } + if !kerning { + for r in &mut set.runs { + r.style.kerning.get_or_insert(0.0); + } + } + let mut out: HashMap = HashMap::new(); + for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { + let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); + *advance += g.advance * lay.fit_scale; + } + out +} + +/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: /// right end), that point of its width. `None` for an empty line. fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { let (first, last) = (line.first()?, line.last()?); diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/text_layout.rs index 3735e7e6e..2ee9e3232 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/text_layout.rs @@ -158,6 +158,184 @@ fn justified_lines_place_each_word() { assert!(words >= 3, "{svg}"); } +/// The only type in `d`. +fn the_text(d: &Document) -> TextObject { + d.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap() +} + +/// A drawn character: its text, where its first glyph is drawn, and the glyph's horizontal scale +/// (font units to user space). +type Drawn = (String, Point, f64); + +/// Each character a usvg draws from `svg` (with the bundled font and the font files of `faces`) +/// and how it draws the character's first glyph, in SVG user space and text order; spaces and +/// tabs are left out. +fn viewer_glyph_positions(svg: &str, faces: &[Arc]) -> Vec { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap()); + for f in faces { + db.load_font_data(f.file_data().to_vec()); + } + let opt = usvg::Options { fontdb: Arc::new(db), ..Default::default() }; + let tree = usvg::Tree::from_str(svg, &opt).unwrap(); + fn walk(g: &usvg::Group, out: &mut Vec) { + for n in g.children() { + match n { + usvg::Node::Group(g) => walk(g, out), + usvg::Node::Text(t) => { + for g in t.layouted().iter().flat_map(|span| &span.positioned_glyphs) { + if g.text.trim().is_empty() { + continue; + } + let m = t.abs_transform().pre_concat(g.transform()); + out.push((g.text.clone(), Point::new(m.tx as f64, m.ty as f64), m.sx as f64)); + } + } + _ => {} + } + } + } + let mut out = vec![]; + walk(tree.root(), &mut out); + out +} + +/// Each character the canvas draws of the only type in `d` and how it draws the character's +/// first glyph, in document space and text order (spaces and tabs left out), with the faces +/// it draws them in. +fn canvas_glyph_positions(d: &Document) -> (Vec, Vec>) { + let t = the_text(d); + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, &t); + let plain = t.plain_text(); + let mut out: Vec = vec![]; + let mut faces: Vec> = vec![]; + let mut last = None; + for g in &lay.glyphs { + let text = if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] }; + if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) { + continue; + } + last = Some(g.byte); + if let Some(f) = db.face_by_id(g.font_id).filter(|f| !faces.iter().any(|o| o.id() == f.id())) { + faces.push(f); + } + let m = t.xf * g.xf; + out.push((text.to_string(), m * Point::ZERO, m.as_coeffs()[0])); + } + (out, faces) +} + +/// Asserts that a viewer draws every character of `d`'s live SVG text where the canvas draws it, +/// as wide as the canvas draws it. +fn assert_drawn_in_place(d: &Document) { + let svg = export(d, &ExportOptions::default()); + let (canvas, faces) = canvas_glyph_positions(d); + let viewer = viewer_glyph_positions(&svg, &faces); + let chars = |v: &[Drawn]| v.iter().map(|(s, _, _)| s.as_str()).collect::(); + assert_eq!(chars(&viewer), chars(&canvas), "{svg}"); + for ((s, v, vs), (_, c, cs)) in viewer.iter().zip(&canvas) { + assert!((v.x - c.x).abs() < 0.05 && (v.y - c.y).abs() < 0.05, "{s}: the viewer draws it at {v:?}, the canvas at {c:?}: {svg}"); + assert!((vs / cs - 1.0).abs() < 0.002, "{s}: the viewer scales it by {vs}, the canvas by {cs}: {svg}"); + } +} + +/// A Justify All line without word spaces spreads its letters; live text keeps the gaps, with +/// tracking and manual kerning too. +#[test] +fn justify_all_lines_keep_their_letter_gaps() { + let tracked = CharStyle { tracking: 80.0, ..style(12.0) }; + let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) }; + for runs in [vec![run("abcdefgh", style(12.0))], vec![run("abc", style(12.0)), run("def", tracked), run("ghi", kerned)]] { + let d = area_doc(runs, 200.0, Justify::JustifyAll); + assert_drawn_in_place(&d); + } +} + +/// Justified and Mojikumi lines whose characters a reader draws where the layout does are written +/// as before: a segment per style run, and per word on a justified line. +#[test] +fn lines_without_moved_characters_are_written_as_before() { + let text = |d: &Document| { + let svg = export(d, &ExportOptions::default()); + svg[svg.find("").unwrap()].to_string() + }; + let justified = area_doc(vec![run("aa bb cc dd ee ff", style(12.0))], 60.0, Justify::JustifyLeft); + let mut composed = area_doc(vec![run("Latin words only", style(12.0))], 200.0, Justify::Center); + let id = composed.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = composed.node_mut(id).map(|n| &mut n.kind) { + t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; + } + let open = |x: &str, anchor: &str| { + format!( + r##""## + ) + }; + let words = + [("aa ", "0", "12"), ("bb ", "15.636", "12"), ("cc ", "32.448", "12"), ("dd", "46.68", "12"), ("ee ", "0", "26.4"), ("ff", "14.304", "26.4")]; + let words: String = words.iter().map(|(w, x, y)| format!(r#"{w}"#)).collect(); + assert_eq!(text(&justified), format!("{}{words}", open("0", ""))); + let centered = format!(r#"{}Latin words only"#, open("100", r#" text-anchor="middle""#)); + assert_eq!(text(&composed), centered); +} + +/// Type scaled horizontally on a justified line is stretched to its own width, not to the +/// justification space after it, nor to the font's kerning against the letter after it when +/// the letters are spread (AV is a kerned pair). +#[test] +fn justified_lines_keep_scaled_type_at_its_width() { + let wide = CharStyle { h_scale: 150.0, ..style(12.0) }; + for justify in [Justify::JustifyLeft, Justify::JustifyAll] { + assert_drawn_in_place(&area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", wide.clone())], 120.0, justify)); + assert_drawn_in_place(&area_doc(vec![run("abcdefgh", wide.clone())], 200.0, justify)); + } + assert_drawn_in_place(&area_doc(vec![run("AVAVAVAV", wide)], 200.0, Justify::JustifyAll)); +} + +/// On lines that aren't spread, the spacing a reader applies itself (tracking, manual kerning, +/// horizontal scale, baseline shift) puts each character where the canvas draws it. +#[test] +fn ragged_lines_keep_their_character_spacing() { + let tracked = CharStyle { tracking: 80.0, ..style(12.0) }; + let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) }; + let wide = CharStyle { h_scale: 150.0, ..style(12.0) }; + let shifted = CharStyle { baseline_shift: 3.0, ..style(12.0) }; + for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] { + let mut runs = + vec![run("plain ", style(12.0)), run("tracked ", tracked.clone()), run("kerned ", kerned.clone()), run("up ", shifted.clone())]; + // usvg anchors a centered or right-aligned line by its width before `textLength` stretches it. + if matches!(justify, Justify::Left | Justify::JustifyLeft) { + runs.push(run("wide ", wide.clone())); + } + runs.push(run("end", style(12.0))); + assert_drawn_in_place(&area_doc(runs, 280.0, justify)); + } +} + +/// Justified Japanese lines spread between their characters (#344); live text keeps the gaps. +#[test] +fn justified_japanese_lines_keep_the_gaps_between_their_characters() { + let text = "日本語の文章は文字と文字の間で両端を揃えます。この段落は三行になります。"; + for justify in [Justify::JustifyLeft, Justify::JustifyAll] { + assert_drawn_in_place(&area_doc(vec![run(text, style(12.0))], 100.0, justify)); + } +} + +/// Japanese composition (Mojikumi) closes up consecutive punctuation and adds a quarter em between +/// Japanese and Latin letters; live text keeps that spacing, on anchored lines too. +#[test] +fn japanese_composition_keeps_its_spacing() { + let text = "「かっこ」、「かな」。漢字とLatinの間。"; + for justify in [Justify::Left, Justify::Center, Justify::Right] { + let mut d = area_doc(vec![run(text, style(12.0))], 280.0, justify); + let id = d.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; + } + assert_drawn_in_place(&d); + } +} + /// Glyph outlines of every text a usvg (with the bundled font) lays out from `svg`, in SVG /// user space. fn viewer_outlines(svg: &str) -> Vec { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 51e614bda..8d2627cee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -612,6 +612,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a ``) wraps them, hidden ones hidden, and a file whose art sits loose under a root `` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask. SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the ` Result<(Document, Vec, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, + /// The event attributes of elements, by id ([`user_events`]). + events: HashMap>, /// Ids of the `` elements marked as layers ([`marked_layer`]). sublayers: HashSet, /// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]). @@ -488,6 +493,10 @@ struct Found { non_scaling: HashSet, /// The `data-*` attributes of elements, by id ([`user_data`]). data: HashMap>, + /// The event attributes of elements, by id ([`user_events`]). + events: HashMap>, + /// The JavaScript files ` + + "#); + let (doc, warnings) = import_with(&text, &ImportOptions { folder: Some("/tmp"), read: Some(&read) }).unwrap(); + let asked = asked.lock().unwrap().clone(); + assert!(asked.iter().any(|p| p.contains("photo.png")) && !asked.iter().any(|p| p.contains("evil")), "read {asked:?}"); + assert_eq!(doc.svg_scripts, ["evil.js"], "each link once, the inline script left out"); + assert!(warnings.iter().any(|w| w.contains("inline scripts were left out")), "{warnings:?}"); + assert_eq!(events(&doc, "shape"), [pair("onclick", "fetch('https://example.com')")]); + assert_eq!(doc.title, "Untitled-1", "the inline script didn't run"); +} +/// Import keeps the first 64 different `