From d202910faafd23a593d85c896d78459fb6ee227e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:45:07 -0700 Subject: [PATCH 01/36] File formats: JPEG XL (.jxl) images open and place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open and File › Place refused a JPEG XL file: Open said it was not a format VectorCraft reads, and the dialogs did not list .jxl. A read-only JPEG XL format joins the format table. Open, Place, drops, copied files and Glass's Load Texture tell it by its first bytes (a bare codestream or the ISOBMFF container). jxl-oxide 0.12.6 (pure Rust, MIT OR Apache-2.0, without its default rayon feature) decodes the first frame, which is stored as a PNG. Orientation is applied, and 16-bit and floating-point samples become 8 bits. Colors are converted to sRGB in XYB-encoded images and when the header names the color space or the ICC profile describes one jxl-oxide knows; other images with an ICC profile keep their samples. Premultiplied colors are divided by alpha before that conversion. CMYK files are refused. The decoder gets only a container's image boxes. It reads the header first, and an image past MAX_RASTER_ALLOC gets the existing "too large to draw" error before any pixels are decoded. Its image buffers are capped at 16 times the image's RGBA size plus 16 MiB, and at most MAX_RASTER_ALLOC. VectorCraft doesn't write JPEG XL yet. Docs: docs/mcp.md, docs/file-format-parity.md, docs/target-app-parity.md, README. --- Cargo.lock | 189 +++++++++++++++ Cargo.toml | 8 + README.md | 2 +- crates/engine/Cargo.toml | 5 + crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/engine/src/cmd/fileio/jxlread.rs | 222 ++++++++++++++++++ crates/engine/src/cmd/fileio/load.rs | 11 +- crates/engine/src/cmd/fileio/mod.rs | 12 +- crates/engine/src/cmd/fileio/tests_jxlread.rs | 213 +++++++++++++++++ crates/engine/tests/import_fuzz.rs | 20 +- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + docs/file-format-parity.md | 1 + docs/mcp.md | 11 +- docs/target-app-parity.md | 3 +- 30 files changed, 739 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/jxlread.rs create mode 100644 crates/engine/src/cmd/fileio/tests_jxlread.rs diff --git a/Cargo.lock b/Cargo.lock index eca47581b..9db51aec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2179,6 +2179,182 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jxl-bitstream" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -4653,6 +4829,8 @@ dependencies = [ "crc32fast", "flate2", "image", + "jxl-color", + "jxl-oxide", "kurbo 0.13.1", "log", "proptest", @@ -4680,6 +4858,8 @@ dependencies = [ "vectorcraft-tools", "vectorcraft-trace", "wat", + "zune-core", + "zune-jpegxl", ] [[package]] @@ -6333,6 +6513,15 @@ dependencies = [ "zune-core", ] +[[package]] +name = "zune-jpegxl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/Cargo.toml b/Cargo.toml index 1c10d551b..0d83e9e8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,14 @@ gif = { version = "0.14", default-features = false, features = ["std", "raii_no_ # decoders, read directly (the image crate converts CMYK to RGB). tiff = "0.11" zune-jpeg = "0.5" +# JPEG XL decoder (pure Rust, MIT OR Apache-2.0): File › Open and Place read `.jxl` images. It is +# built without its default `rayon` feature and decodes on one thread. +jxl-oxide = { version = "0.12.6", default-features = false } +# jxl-oxide's color conversions, run on colors after their alpha is divided out. +jxl-color = "0.11.0" +# Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files. +zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] } +zune-core = "0.5" # ui egui = "0.36" diff --git a/README.md b/README.md index 36fe11fe9..79537ee6b 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the fu all of Illustrator): everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and -files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, JPEG XL in, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index c51728ef2..a9139af81 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,8 @@ vectorcraft-plugins = { workspace = true } vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } +jxl-oxide = { workspace = true } +jxl-color = { workspace = true } flate2 = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } @@ -49,3 +51,6 @@ proptest = { workspace = true } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } +# JPEG XL files for the `.jxl` open and place tests. +zune-jpegxl = { workspace = true } +zune-core = { workspace = true } diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 6a17e285b..d5f5cfaeb 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Load Image into Clipboard", [], None, - "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste", + "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD, PSB or JPEG XL bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste", has_doc, import_image ), diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs new file mode 100644 index 000000000..1826c43df --- /dev/null +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -0,0 +1,222 @@ +//! Reading JPEG XL images (`.jxl`, ISO/IEC 18181) for File › Open and File › Place, with the +//! jxl-oxide decoder: the bare codestream and the ISOBMFF container. The first frame of an +//! animation is read, with the orientation its header gives applied. +//! +//! Colors come back as sRGB whenever the decoder converts them without a color management +//! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC +//! profiles that describe one of them. Any other image with an embedded ICC profile comes back +//! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! and CMYK images are refused. The file is untrusted: the decoder is given only a container's +//! image boxes, the image's size is checked from its header before any pixels are decoded, and the +//! decoder's image buffers are capped. + +use jxl_color::ColorTransform; +use jxl_oxide::color::{ColourEncoding, TransferFunction}; +use jxl_oxide::{AllocTracker, ColorEncodingWithProfile, EnumColourEncoding, HdrType, InitializeResult, JxlImage, NullCms, RenderingIntent}; + +/// A bare JPEG XL codestream starts with these bytes. +const CODESTREAM: [u8; 2] = [0xff, 0x0a]; +/// The signature box an ISOBMFF JPEG XL container starts with. +const CONTAINER: [u8; 12] = [0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a]; +/// How many bytes the decoder is given first to read the header. Each later try gives it twice as +/// many bytes, and each try decodes the embedded ICC profile from its start again. +const HEAD: usize = 4096; +/// The most the decoder's buffers may take while it reads the header. Its `AllocTracker` counts the +/// image buffers only: an embedded ICC profile is decoded into buffers of its own, which jxl-color +/// limits to 256 MiB. +const HEADER_BUFFERS: usize = 16 << 20; +/// The most the decoder's buffers may take for the pixels: this many times the image's size as +/// RGBA, plus [`HEADER_BUFFERS`], and never more than the `max_alloc` of [`decode`], the limit the +/// `image` crate's decoders get in `raster_image`. The decoder keeps each channel as 32-bit +/// samples: a lossless 16-bit RGBA image of 1024 × 768 pixels took 12 times its size as RGBA. +const DECODER_BUFFERS: u64 = 16; + +const TRUNCATED: &str = "the JPEG XL file is cut short"; + +/// Is `bytes` a JPEG XL image (a bare codestream or a container)? +pub fn is_jxl(bytes: &[u8]) -> bool { + bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER) +} + +/// The first frame of a JPEG XL image as straight RGBA. Fails, without decoding the pixels, when +/// the image would take more than `max_alloc` bytes as RGBA. +pub fn decode(bytes: &[u8], max_alloc: u64) -> Result { + let pixel_boxes = pixel_boxes(bytes); + let bytes = pixel_boxes.as_deref().unwrap_or(bytes); + // The decoder reads the header first, with buffers for the header alone. + let (head, _) = header(bytes, HEADER_BUFFERS)?; + let (w, h) = (head.width(), head.height()); + let rgba = u64::from(w) * u64::from(h) * 4; + if rgba > max_alloc { + return Err(format!("an image of {w} × {h} pixels is too large to draw")); + } + if head.pixel_format().has_black() { + return Err("CMYK JPEG XL images can't be read".into()); + } + // Then it reads the file again until the first frame is loaded, with buffers for the pixels. + let budget = rgba.saturating_mul(DECODER_BUFFERS).saturating_add(HEADER_BUFFERS as u64).min(max_alloc); + let budget = usize::try_from(budget).unwrap_or(usize::MAX); + let (mut image, mut at) = header(bytes, budget)?; + let mut end = at; + while image.num_loaded_keyframes() == 0 { + if end == bytes.len() { + return Err(TRUNCATED.into()); + } + end = end.saturating_mul(2).max(HEAD).min(bytes.len()); + at += image.feed_bytes(&bytes[at..end]).map_err(unreadable)?; + } + let metadata = &image.image_header().metadata; + let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true); + let intent = RenderingIntent::Relative; + let srgb = if metadata.grayscale() { EnumColourEncoding::gray_srgb(intent) } else { EnumColourEncoding::srgb(intent) }; + // Premultiplied colors are divided by alpha in the encoding they are stored in, and converted + // to sRGB after that. XYB-encoded images come from the decoder in linear sRGB (tone mapped when + // they are HDR), which the sRGB transfer curve alone turns into sRGB. + let (request, convert) = match (to_srgb_without_cms(metadata.xyb_encoded, image.original_icc()), associated) { + (false, _) => (None, None), + (true, false) => (Some(srgb), None), + (true, true) if metadata.xyb_encoded => (Some(EnumColourEncoding { tf: TransferFunction::Linear, ..srgb }), Some(ToSrgb::Curve)), + (true, true) => { + let stored = match (&metadata.colour_encoding, image.original_icc()) { + (ColourEncoding::Enum(e), _) => ColorEncodingWithProfile::new(e.clone()), + (_, Some(icc)) => ColorEncodingWithProfile::with_icc(icc).map_err(unreadable)?, + (_, None) => return Err(unreadable("no color encoding")), + }; + let mut builder = ColorTransform::builder(); + builder.from_pq(image.hdr_type() == Some(HdrType::Pq)); + let to = ColorEncodingWithProfile::new(srgb); + let t = builder.build(&stored, &to, &metadata.opsin_inverse_matrix, &metadata.tone_mapping, &NullCms).map_err(unreadable)?; + (None, Some(ToSrgb::Transform(t))) + } + }; + if let Some(encoding) = request { + image.request_color_encoding(encoding); + } + let render = image.render_frame(0).map_err(unreadable)?; + let mut stream = render.stream(); + let channels = stream.channels() as usize; + if !(1..=4).contains(&channels) { + return Err(unreadable(format!("{channels} channels"))); + } + let mut out = image::RgbaImage::new(stream.width(), stream.height()); + let width = out.width() as usize; + let mut row = vec![0f32; width * channels]; + let mut colors = [vec![0f32; width], vec![0f32; width], vec![0f32; width]]; + for line in out.chunks_exact_mut((width * 4).max(4)) { + if stream.write_to_buffer(&mut row) != row.len() { + return Err(TRUNCATED.into()); + } + to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?; + } + Ok(out) +} + +/// How colors whose alpha was divided out become sRGB. +pub(crate) enum ToSrgb { + /// A conversion from the encoding the colors are stored in. + Transform(ColorTransform), + /// The sRGB transfer curve, for colors in linear sRGB. + Curve, +} + +/// One row of samples, `channels` to a pixel (gray or RGB, then alpha when there are 2 or 4), as +/// straight RGBA bytes in `out`: colors divided by alpha when it is `associated`, then converted +/// with `convert`. `colors` holds a row of red, green and blue; a gray sample goes into all three, +/// as jxl-render's `clone_gray` does before its own conversions. +pub(crate) fn to_rgba( + row: &[f32], + channels: usize, + associated: bool, + convert: Option<&ToSrgb>, + colors: &mut [Vec; 3], + out: &mut [u8], +) -> Result<(), String> { + let alpha = |s: &[f32]| if matches!(channels, 2 | 4) { s.last().map_or(1.0, |a| a.clamp(0.0, 1.0)) } else { 1.0 }; + let gray = channels < 3; + for (x, s) in row.chunks_exact(channels).enumerate() { + let a = alpha(s); + for (i, plane) in colors.iter_mut().enumerate() { + let v = s.get(if gray { 0 } else { i }).copied().unwrap_or(0.0); + if let Some(c) = plane.get_mut(x) { + *c = if associated && a > 0.0 { v / a } else { v }; + } + } + } + // The conversion leaves gray in the first row when it gives one channel. + let gray = match convert { + Some(ToSrgb::Transform(t)) => { + let mut planes: Vec<&mut [f32]> = colors.iter_mut().map(Vec::as_mut_slice).collect(); + t.run(&mut planes).map_err(unreadable)? < 3 + } + Some(ToSrgb::Curve) => { + colors.iter_mut().flatten().for_each(|v| *v = if *v <= 0.003_130_8 { *v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }); + gray + } + None => gray, + }; + for (x, (px, s)) in out.as_chunks_mut::<4>().0.iter_mut().zip(row.chunks_exact(channels)).enumerate() { + let c = |i: usize| colors.get(if gray { 0 } else { i }).and_then(|p| p.get(x)).copied().unwrap_or(0.0); + *px = [byte(c(0)), byte(c(1)), byte(c(2)), byte(alpha(s))]; + } + Ok(()) +} + +/// A container's signature and the boxes the decoder reads pixels from (`ftyp`, `jxll`, `jxlc` and +/// `jxlp`). Every other box is left out: Exif, XMP, JPEG reconstruction data and Brotli-compressed +/// `brob` boxes, which the decoder would otherwise store and unpack without a size limit. A box +/// cut short ends the file. `None` for a bare codestream. +fn pixel_boxes(bytes: &[u8]) -> Option> { + let mut rest = bytes.strip_prefix(&CONTAINER)?; + let mut out = CONTAINER.to_vec(); + while let (Some(size), Some(ty)) = (rest.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()), rest.get(4..8)) { + let (head, len) = match u32::from_be_bytes(size) { + 0 => (8, rest.len() as u64), + 1 => match rest.get(8..16).and_then(|l| l.try_into().ok()) { + Some(l) => (16, u64::from_be_bytes(l)), + None => break, + }, + n => (8, u64::from(n)), + }; + if len < head { + break; + } + let len = usize::try_from(len).unwrap_or(usize::MAX).min(rest.len()); + let (b, after) = rest.split_at(len); + if matches!(ty, b"ftyp" | b"jxll" | b"jxlc" | b"jxlp") { + out.extend_from_slice(b); + } + rest = after; + } + Some(out) +} + +/// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read +/// the header → (the decoder, how many bytes it took). +fn header(bytes: &[u8], buffers: usize) -> Result<(JxlImage, usize), String> { + let mut uninit = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(buffers)).build_uninit(); + let (mut at, mut end) = (0, 0usize); + loop { + end = end.saturating_mul(2).max(HEAD).min(bytes.len()); + at += uninit.feed_bytes(&bytes[at..end]).map_err(unreadable)?; + match uninit.try_init().map_err(unreadable)? { + InitializeResult::Initialized(image) => return Ok((image, at)), + InitializeResult::NeedMoreData(_) if end == bytes.len() => return Err(TRUNCATED.into()), + InitializeResult::NeedMoreData(more) => uninit = more, + } + } +} + +/// Does the decoder convert to sRGB without a color management system? It does for XYB-encoded +/// images and the color spaces a header names; for an embedded ICC profile, only when the profile +/// describes one of those. +fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool { + xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty())) +} + +fn unreadable(e: impl std::fmt::Display) -> String { + format!("the JPEG XL file can't be read: {e}") +} + +fn byte(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 58200162c..a959ecbf2 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -9,7 +9,7 @@ use vectorcraft_render::effects::pixel::LoadedTexture; use super::super::*; use super::pdfimport::LoadOptions; -use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file}; +use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, jxlread, psdread, read_file}; use crate::cmd::links::Resolved; use crate::{EngineError, Prefs}; @@ -93,6 +93,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if psdread::is_psd(bytes) { return format(if psdread::is_psb(bytes) { "psb" } else { "psd" }); } + if jxlread::is_jxl(bytes) { + return format("jxl"); + } if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } @@ -451,13 +454,17 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option { /// 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. +/// other extra channels, which the decoder can't read either, become RGBA too. JPEG XL images are +/// read with [`jxlread`] and stored as PNG. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); 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); } + if jxlread::is_jxl(bytes) { + return as_png(jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + } let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; let mut limits = image::Limits::default(); limits.max_alloc = Some(MAX_RASTER_ALLOC); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 93282d33d..868d7d8f0 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -30,6 +30,7 @@ mod encode; pub mod eps; mod export; pub(crate) mod imagemap; +pub mod jxlread; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note}; @@ -641,6 +642,8 @@ pub const FORMATS: &[Format] = &[ reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false), // Photoshop's large document format: read like a PSD (its merged image), never written. reader("psb", "PSB", &["psb"], "image/x-psb", true), + // jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it. + reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -662,6 +665,7 @@ pub const OPEN_EXTS: &[&str] = &[ "bmp", "psd", "psb", + "jxl", "vctemplate", "dxf", "emf", @@ -674,8 +678,9 @@ pub const OPEN_EXTS: &[&str] = &[ /// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop /// documents belong to raster editors, so Finder and file managers don't offer a vector app for -/// them (File › Open and Place still read them). -pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"]; +/// them. The macOS bundle doesn't declare JPEG XL images either. File › Open and Place read them +/// all. +pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "jxl"]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares /// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order. @@ -711,6 +716,7 @@ pub const PLACE_EXTS: &[&str] = &[ "bmp", "psd", "psb", + "jxl", "vctemplate", "dxf", "emf", @@ -1205,6 +1211,8 @@ mod tests_pdfoutput; #[cfg(test)] mod tests_tiffbmp; +#[cfg(test)] +mod tests_jxlread; #[cfg(test)] mod tests_pdfx; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs new file mode 100644 index 000000000..e4d3a1aad --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -0,0 +1,213 @@ +//! Reading JPEG XL images: the bare codestream and the container, alpha, 16-bit samples, the size +//! check before decoding, and damaged files refused without a panic. + +use serde_json::json; +use zune_core::bit_depth::BitDepth; +use zune_core::colorspace::ColorSpace; +use zune_core::options::EncoderOptions; + +use super::jxlread::{ToSrgb, decode, is_jxl, to_rgba}; +use super::*; + +/// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in +/// native byte order. +fn jxl(w: usize, h: usize, space: ColorSpace, depth: BitDepth, samples: &[u8]) -> Vec { + let mut out = vec![]; + zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(w, h, space, depth)).encode(&mut out).unwrap(); + out +} + +/// `codestream` in the ISOBMFF container: the signature box, `ftyp` and a `jxlc` box holding it. +fn container(codestream: &[u8]) -> Vec { + let mut f = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a]; + f.extend(20u32.to_be_bytes()); + f.extend(b"ftypjxl \0\0\0\0jxl "); + f.extend((8 + codestream.len() as u32).to_be_bytes()); + f.extend(b"jxlc"); + f.extend(codestream); + f +} + +/// A 3 × 2 RGBA image: opaque red, green and blue, then half-transparent, clear and opaque gray. +const RGBA: [[u8; 4]; 6] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [200, 100, 50, 128], [9, 9, 9, 0], [77, 77, 77, 255]]; + +fn rgba8() -> Vec { + jxl(3, 2, ColorSpace::RGBA, BitDepth::Eight, RGBA.as_flattened()) +} + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// The pixels of the one image the active document holds. +fn stored_pixels(s: &Session) -> Vec<[u8; 4]> { + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.images.len(), 1); + let blob = doc.images.values().next().unwrap(); + assert_eq!(blob.mime, "image/png", "stored as PNG"); + image::load_from_memory(&blob.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect() +} + +#[test] +fn jxl_images_open_and_place_with_their_pixels_and_alpha() { + let bare = rgba8(); + let boxed = container(&bare); + assert!(is_jxl(&bare) && is_jxl(&boxed) && !is_jxl(b"\xff\xd8\xff")); + for (name, bytes) in [("bare.jxl", &bare), ("boxed.jxl", &boxed), ("named-wrong.png", &boxed)] { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(r["format"], "jxl", "{name}: told by its content"); + let ab = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((ab.width(), ab.height()), (3.0, 2.0), "{name}: 72 ppi, one point a pixel"); + assert_eq!(stored_pixels(&s), RGBA, "{name}"); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &json!({"name": name, "dataBase64": b64(bytes)})).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(3.0), Some(2.0)), "{name}: {r}"); + assert_eq!(stored_pixels(&s), RGBA, "{name}: placed"); + } + assert!(OPEN_EXTS.contains(&"jxl") && PLACE_EXTS.contains(&"jxl")); + assert!(open_filters().any(|f| f == ("JPEG XL", &["jxl"][..])) && place_filters().any(|f| f == ("JPEG XL", &["jxl"][..]))); +} + +/// A `.jxl` file copied in a file manager pastes as its image. +#[test] +fn a_copied_jxl_file_pastes_as_its_image() { + use crate::cmd::clipboard::{FILE_HEAD, PASTE_ORDER, file_flavour}; + let f = container(&rgba8()); + let mime = file_flavour("copied.jxl", &f[..f.len().min(FILE_HEAD)], &PASTE_ORDER); + assert_eq!(mime, Some("image/jxl")); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("clipboard.importImage", &json!({"dataBase64": b64(&f), "mime": mime})).unwrap(); + assert_eq!((r["pixelWidth"].as_u64(), r["pixelHeight"].as_u64()), (Some(3), Some(2)), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(stored_pixels(&s), RGBA); +} + +#[test] +fn sixteen_bit_samples_become_eight_bits() { + let rgb: [[u16; 3]; 4] = [[0x0000, 0x8080, 0xffff], [0x1234, 0x80ff, 0x7f00], [0xff00, 0x00ff, 0x4000], [0xc000, 0x0101, 0xfefe]]; + let samples: Vec = rgb.as_flattened().iter().flat_map(|v| v.to_ne_bytes()).collect(); + let f = jxl(2, 2, ColorSpace::RGB, BitDepth::Sixteen, &samples); + let to8 = |v: u16| (f64::from(v) / 65535.0 * 255.0).round() as u8; + let want: Vec<[u8; 4]> = rgb.iter().map(|p| [to8(p[0]), to8(p[1]), to8(p[2]), 255]).collect(); + assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::>(), want); + // Gray, 16 bits, in the container, opened as a document. + let gray: Vec = [0xffffu16, 0x8080, 0x0000, 0x4040].iter().flat_map(|v| v.to_ne_bytes()).collect(); + let f = container(&jxl(2, 2, ColorSpace::Luma, BitDepth::Sixteen, &gray)); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "gray.jxl", "dataBase64": b64(&f)})).unwrap(); + assert_eq!(stored_pixels(&s), [[255, 255, 255, 255], [128, 128, 128, 255], [0, 0, 0, 255], [64, 64, 64, 255]]); +} + +/// A bare codestream that is only a header: `w` × `h` pixels and default metadata. +fn header_only(w: u32, h: u32) -> Vec { + // Each field's bits, read from the lowest bit of each byte up: `div8`, the height as a U32 + // (selector 3: 1 + 30 bits), `ratio` 0, the width likewise, then `all_default` for the + // metadata and `default_m` for the transform data. + let fields = [(0, 1), (3, 2), (h - 1, 30), (0, 3), (3, 2), (w - 1, 30), (1, 1), (1, 1)]; + let (mut out, mut acc, mut n) = (vec![0xff, 0x0a], 0u64, 0); + for (v, bits) in fields { + acc |= u64::from(v) << n; + n += bits; + while n >= 8 { + out.push(acc as u8); + (acc, n) = (acc >> 8, n - 8); + } + } + out.push(acc as u8); + out +} + +#[test] +fn an_image_too_large_is_refused_from_its_header() { + // The header of a 100000 × 100000 image (40 GB as RGBA), with no pixel data after it. + let huge = header_only(100_000, 100_000); + assert_eq!(decode(&huge, MAX_RASTER_ALLOC).unwrap_err(), "an image of 100000 × 100000 pixels is too large to draw"); + let mut s = Session::new(); + let e = s.execute("document.open", &json!({"name": "huge.jxl", "dataBase64": b64(&huge)})).unwrap_err().to_string(); + assert!(e.contains("too large to draw"), "{e}"); + // The allowance counts RGBA bytes: 3 × 2 pixels take 24 (the decoder's own buffers then need more). + assert!(decode(&rgba8(), 23).unwrap_err().contains("too large")); + assert!(!decode(&rgba8(), 24).unwrap_err().contains("too large")); + assert!(decode(&rgba8(), 1 << 20).is_ok()); +} + +/// Premultiplied colors are divided by their alpha before they are converted to sRGB: a linear +/// 0.25 at alpha 0.5 is a straight linear 0.5, which sRGB stores as 0.735 (188 of 255). +#[test] +fn associated_alpha_is_divided_out_before_the_color_conversion() { + use jxl_oxide::{ColorEncodingWithProfile, EnumColourEncoding, NullCms, RenderingIntent}; + let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative); + let linear = EnumColourEncoding { tf: jxl_oxide::color::TransferFunction::Linear, ..srgb.clone() }; + let header = jxl_oxide::JxlImage::builder().read(&rgba8()[..]).unwrap(); + let m = &header.image_header().metadata; + let to_srgb = ToSrgb::Transform( + jxl_color::ColorTransform::new( + &ColorEncodingWithProfile::new(linear), + &ColorEncodingWithProfile::new(srgb), + &m.opsin_inverse_matrix, + &m.tone_mapping, + &NullCms, + ) + .unwrap(), + ); + let mut colors = [vec![0.0; 2], vec![0.0; 2], vec![0.0; 2]]; + let mut out = [0u8; 8]; + for convert in [&to_srgb, &ToSrgb::Curve] { + to_rgba(&[0.25, 0.25, 0.25, 0.5, 0.0, 0.0, 0.0, 0.0], 4, true, Some(convert), &mut colors, &mut out).unwrap(); + assert_eq!(out, [188, 188, 188, 128, 0, 0, 0, 0]); + // Straight alpha is left as it is. + to_rgba(&[0.25, 0.25, 0.25, 0.5, 1.0, 1.0, 1.0, 1.0], 4, false, Some(convert), &mut colors, &mut out).unwrap(); + assert_eq!(out, [137, 137, 137, 128, 255, 255, 255, 255]); + } + // Gray with another white point converts through three channels and comes back as gray. + let gray_e = EnumColourEncoding { + white_point: jxl_oxide::color::WhitePoint::E, + tf: jxl_oxide::color::TransferFunction::Linear, + ..EnumColourEncoding::gray_srgb(RenderingIntent::Relative) + }; + let gray_srgb = ColorEncodingWithProfile::new(EnumColourEncoding::gray_srgb(RenderingIntent::Relative)); + let to_gray = + jxl_color::ColorTransform::new(&ColorEncodingWithProfile::new(gray_e), &gray_srgb, &m.opsin_inverse_matrix, &m.tone_mapping, &NullCms) + .unwrap(); + to_rgba(&[0.25, 0.5, 1.0, 1.0], 2, true, Some(&ToSrgb::Transform(to_gray)), &mut colors, &mut out).unwrap(); + assert_eq!(out, [188, 188, 188, 128, 255, 255, 255, 255]); +} + +/// A container's Exif and Brotli-compressed boxes are left out: an unreadable `brob` box before the +/// codestream doesn't stop the image from opening. +#[test] +fn metadata_boxes_are_not_read() { + let mut f = container(&[]); + f.truncate(32); // the signature and `ftyp` + let mut boxed = |ty: &[u8], body: &[u8]| { + f.extend((8 + body.len() as u32).to_be_bytes()); + f.extend(ty); + f.extend(body); + }; + boxed(b"Exif", &[0, 0, 0, 0, b'M', b'M', 0, 42]); + boxed(b"brob", &[b'x', b'm', b'l', b' ', 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + boxed(b"jxlc", &rgba8()); + assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::>(), RGBA); +} + +#[test] +fn damaged_files_are_refused_without_a_panic() { + let good = rgba8(); + for n in 0..good.len() { + assert!(decode(&good[..n], 1 << 30).is_err(), "cut at {n}"); + } + // Every byte changed in turn: an error or an image, never a panic. + for i in 0..good.len() { + for v in [0, 1, 0x7f, 0x80, 0xff] { + let mut f = good.clone(); + f[i] = v; + let _ = decode(&f, 1 << 30); + } + } + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let cut = &good[..good.len() - 3]; + assert!(s.execute("file.place", &json!({"name": "cut.jxl", "dataBase64": b64(cut)})).is_err()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index a6553a997..8733ce684 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,5 +1,5 @@ //! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, -//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset +//! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, shortcut sets other apps save as text (`.kys`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may //! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records @@ -1253,6 +1253,22 @@ fn raster_samples() -> Vec<(&'static str, Vec)> { psb.extend((0..8).flat_map(|_| 2u32.to_be_bytes())); psb.extend((0..8u8).flat_map(|v| [0xfe, v * 30])); out.push(("a.psb", psb)); + // JPEG XL: an 8-bit RGBA codestream, and a 16-bit gray one in the container. + use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; + let jxl = |space, depth, samples: &[u8]| { + let mut b = vec![]; + zune_jpegxl::JxlSimpleEncoder::new(samples, EncoderOptions::new(5, 3, space, depth)).encode(&mut b).unwrap(); + b + }; + out.push(("a.jxl", jxl(ColorSpace::RGBA, BitDepth::Eight, &(0..60).map(|i| (i * 17) as u8).collect::>()))); + let gray = jxl(ColorSpace::Luma, BitDepth::Sixteen, &(0..30).map(|i| (i * 41) as u8).collect::>()); + let mut boxed = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a]; + boxed.extend(20u32.to_be_bytes()); + boxed.extend(b"ftypjxl \0\0\0\0jxl "); + boxed.extend((8 + gray.len() as u32).to_be_bytes()); + boxed.extend(b"jxlc"); + boxed.extend(gray); + out.push(("b.jxl", boxed)); out } @@ -1262,7 +1278,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/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b289026ba..60b2c632b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4498,6 +4498,9 @@ imageTrace Outline Obrys @msg the PSD file is cut short soubor PSD je useknutý @msg the PSD file's header is damaged hlavička souboru PSD je poškozená @msg the PSD file's image data can't be read: {e} obrazová data souboru PSD nelze přečíst: {e} +@msg the JPEG XL file is cut short soubor JPEG XL je useknutý +@msg CMYK JPEG XL images can't be read obrázky JPEG XL v režimu CMYK nelze přečíst +@msg the JPEG XL file can't be read: {e} soubor JPEG XL nelze přečíst: {e} @msg Imported library {name} Importována knihovna {name} @msg Invalid panel layout; restored the default dock Neplatné rozložení panelů; obnoveno výchozí ukotvení @msg action is required action je povinné diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0dd943820..8ae8c8240 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4208,6 +4208,9 @@ theme Appearance Erscheinungsbild @msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel) @msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden +@msg the JPEG XL file is cut short die JPEG-XL-Datei ist unvollständig +@msg CMYK JPEG XL images can't be read JPEG-XL-Bilder im CMYK-Modus können nicht gelesen werden +@msg the JPEG XL file can't be read: {e} die JPEG-XL-Datei kann nicht gelesen werden: {e} Value Axis Werteachse On Left Side Links On Right Side Rechts diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 56703aea0..7ff10ce92 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4191,6 +4191,9 @@ theme Appearance Εμφάνιση @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 the JPEG XL file is cut short το αρχείο JPEG XL είναι περικομμένο +@msg CMYK JPEG XL images can't be read οι εικόνες JPEG XL σε CMYK δεν διαβάζονται +@msg the JPEG XL file can't be read: {e} το αρχείο JPEG XL δεν μπορεί να διαβαστεί: {e} Create New Fields by Δημιουργία νέων πεδίων κατά @msg Imported library {name} Εισήχθη η βιβλιοθήκη {name} RGB: RGB: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4c6b07cb3..9b0f3dc24 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4223,6 +4223,9 @@ axis Both Ambos @msg the PSD file is cut short el archivo PSD está incompleto @msg the PSD file's header is damaged el encabezado del archivo PSD está dañado @msg the PSD file's image data can't be read: {e} no se pueden leer los datos de imagen del archivo PSD: {e} +@msg the JPEG XL file is cut short el archivo JPEG XL está incompleto +@msg CMYK JPEG XL images can't be read las imágenes JPEG XL en CMYK no se pueden leer +@msg the JPEG XL file can't be read: {e} no se puede leer el archivo JPEG XL: {e} Video Video De-Interlace… Desentrelazar… De-Interlace Desentrelazar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2750d8a0..d7f85b8a7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4420,6 +4420,9 @@ imageTrace Outline Ääriviiva @msg the PSD file is cut short tämä PSD-tiedosto on katkennut @msg the PSD file's header is damaged tämän PSD-tiedoston otsake on vaurioitunut @msg the PSD file's image data can't be read: {e} tämän PSD-tiedoston kuvadataa ei voi lukea: {e} +@msg the JPEG XL file is cut short tämä JPEG XL -tiedosto on katkennut +@msg CMYK JPEG XL images can't be read CMYK-tilassa olevia JPEG XL -kuvia ei voi lukea +@msg the JPEG XL file can't be read: {e} tätä JPEG XL -tiedostoa ei voi lukea: {e} @msg Imported library {name} Tuotiin kirjasto {name} @msg Invalid panel layout; restored the default dock Virheellinen paneeliasettelu; oletustelakka palautettiin @msg action is required action vaaditaan diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 03146f17a..e7e5f7435 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4234,6 +4234,9 @@ axis Both Les deux @msg the PSD file is cut short le fichier PSD est tronqué @msg the PSD file's header is damaged l'en-tête du fichier PSD est endommagé @msg the PSD file's image data can't be read: {e} les données d'image du fichier PSD ne peuvent pas être lues : {e} +@msg the JPEG XL file is cut short le fichier JPEG XL est tronqué +@msg CMYK JPEG XL images can't be read les images JPEG XL en CMJN ne peuvent pas être lues +@msg the JPEG XL file can't be read: {e} le fichier JPEG XL ne peut pas être lu : {e} Video Vidéo De-Interlace… Désentrelacement… De-Interlace Désentrelacement diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 461070dcf..338e9fcee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4699,6 +4699,9 @@ imageTrace Outline Körvonal @msg the PSD file is cut short a PSD-fájl csonka @msg the PSD file's header is damaged a PSD-fájl fejléce sérült @msg the PSD file's image data can't be read: {e} a PSD-fájl képadatai nem olvashatók: {e} +@msg the JPEG XL file is cut short a JPEG XL-fájl csonka +@msg CMYK JPEG XL images can't be read CMYK módú JPEG XL-képek nem olvashatók +@msg the JPEG XL file can't be read: {e} a JPEG XL-fájl nem olvasható: {e} @msg the PSD file's image data uses an unknown compression ({m}) a PSD-fájl képadatai ismeretlen tömörítést használnak ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) a PSD-kép túl nagy a beolvasáshoz ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open a PDF mentése párbeszédablak nem nyílt meg diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 21cfcf18a..08fa20dfd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4226,6 +4226,9 @@ axis Both Entrambi @msg the PSD file is cut short il file PSD è troncato @msg the PSD file's header is damaged l'intestazione del file PSD è danneggiata @msg the PSD file's image data can't be read: {e} i dati immagine del file PSD non possono essere letti: {e} +@msg the JPEG XL file is cut short il file JPEG XL è troncato +@msg CMYK JPEG XL images can't be read le immagini JPEG XL in CMYK non possono essere lette +@msg the JPEG XL file can't be read: {e} il file JPEG XL non può essere letto: {e} Video Video De-Interlace… Deinterlaccia… De-Interlace Deinterlaccia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 525c112f4..c8b8d35bf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4265,6 +4265,9 @@ swatch Bright Violet ビビッドバイオレット @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 the JPEG XL file is cut short JPEG XL ファイルが途中で切れています +@msg CMYK JPEG XL images can't be read CMYK の JPEG XL 画像は読み込めません +@msg the JPEG XL file can't be read: {e} JPEG XL ファイルを読み込めません:{e} Video ビデオ De-Interlace… インターレース解除… De-Interlace インターレース解除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6443cfcc4..141a6af9e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4197,6 +4197,9 @@ theme Appearance 외관 @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 the JPEG XL file is cut short JPEG XL 파일이 잘려 있습니다 +@msg CMYK JPEG XL images can't be read CMYK JPEG XL 이미지는 읽을 수 없습니다 +@msg the JPEG XL file can't be read: {e} JPEG XL 파일을 읽을 수 없습니다: {e} Video 비디오 De-Interlace… 디인터레이스… De-Interlace 디인터레이스 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae9765d1d..c17a6bbb7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4316,6 +4316,9 @@ imageTrace Outline Kontur @msg the PSD file is cut short plik PSD jest ucięty @msg the PSD file's header is damaged nagłówek pliku PSD jest uszkodzony @msg the PSD file's image data can't be read: {e} nie można odczytać danych obrazu pliku PSD: {e} +@msg the JPEG XL file is cut short plik JPEG XL jest ucięty +@msg CMYK JPEG XL images can't be read nie można odczytać obrazów JPEG XL w trybie CMYK +@msg the JPEG XL file can't be read: {e} nie można odczytać pliku JPEG XL: {e} @msg Imported library {name} Zaimportowano bibliotekę {name} @msg Invalid panel layout; restored the default dock Nieprawidłowy układ paneli; przywrócono domyślny dok @msg action is required wymagane jest action diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 63e725edf..40cafa014 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3785,6 +3785,9 @@ axis Both Ambos @msg the PSD file is cut short o arquivo PSD está incompleto @msg the PSD file's header is damaged o cabeçalho do arquivo PSD está danificado @msg the PSD file's image data can't be read: {e} os dados de imagem do arquivo PSD não podem ser lidos: {e} +@msg the JPEG XL file is cut short o arquivo JPEG XL está incompleto +@msg CMYK JPEG XL images can't be read imagens JPEG XL em CMYK não podem ser lidas +@msg the JPEG XL file can't be read: {e} o arquivo JPEG XL não pode ser lido: {e} @msg Imported library {name} Biblioteca {name} importada Define a variable first Defina uma variável primeiro Text Texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ee7c6e5cd..381bffb8a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4224,6 +4224,9 @@ axis Both Оба @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 the JPEG XL file is cut short файл JPEG XL обрезан +@msg CMYK JPEG XL images can't be read изображения JPEG XL в режиме CMYK не читаются +@msg the JPEG XL file can't be read: {e} не удаётся прочитать файл JPEG XL: {e} Video Видео De-Interlace… Устранение чересстрочности… De-Interlace Устранение чересстрочности diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb6ef9f23..b43da5db5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4159,6 +4159,9 @@ axis Both Båda @msg the PSD file is cut short PSD-filen är avkortad @msg the PSD file's header is damaged PSD-filens huvud är skadat @msg the PSD file's image data can't be read: {e} PSD-filens bilddata kan inte läsas: {e} +@msg the JPEG XL file is cut short JPEG XL-filen är avkortad +@msg CMYK JPEG XL images can't be read JPEG XL-bilder i CMYK kan inte läsas +@msg the JPEG XL file can't be read: {e} JPEG XL-filen kan inte läsas: {e} Video Video De-Interlace… Avfläta… De-Interlace Avfläta diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d80b7657e..80879db6a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4210,6 +4210,9 @@ axis Both Обидві @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 the JPEG XL file is cut short файл JPEG XL обрізаний +@msg CMYK JPEG XL images can't be read зображення JPEG XL у режимі CMYK не читаються +@msg the JPEG XL file can't be read: {e} не вдається прочитати файл JPEG XL: {e} Video Відео De-Interlace… Усунення черезрядковості… De-Interlace Усунення черезрядковості diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 68faa3b09..c91ef3551 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4337,6 +4337,9 @@ theme Appearance Giao diện @msg the PSD file is cut short tệp PSD bị cắt ngắn @msg the PSD file's header is damaged tiêu đề tệp PSD bị hỏng @msg the PSD file's image data can't be read: {e} dữ liệu ảnh tệp PSD không thể đọc được: {e} +@msg the JPEG XL file is cut short tệp JPEG XL bị cắt ngắn +@msg CMYK JPEG XL images can't be read ảnh JPEG XL hệ màu CMYK không thể đọc được +@msg the JPEG XL file can't be read: {e} tệp JPEG XL không thể đọc được: {e} @msg the PSD file's image data uses an unknown compression ({m}) dữ liệu ảnh tệp PSD dùng nén không xác định ({m}) @msg the PSD image is too large to read ({_1} × {_2} pixels) ảnh PSD quá lớn để đọc ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open hộp thoại Lưu PDF không mở diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bc23a1fcf..09a541f3f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4029,6 +4029,9 @@ lighting Light 光源 @msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys} Load EPS into Clipboard 将 EPS 载入剪贴板 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制 +@msg the JPEG XL file is cut short JPEG XL 文件不完整 +@msg CMYK JPEG XL images can't be read 无法读取 CMYK 模式的 JPEG XL 图像 +@msg the JPEG XL file can't be read: {e} 无法读取 JPEG XL 文件:{e} Fill and Stroke Controls 填色和描边控件 Coloring Controls 颜色控件 Draw Mode Controls 绘图模式控件 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b848ff79b..7fcfa327f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -93,6 +93,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 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) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | +| JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | | PICT, PCX, Pixar PXR | open, place (legacy) | no | no | rarely used | diff --git a/docs/mcp.md b/docs/mcp.md index b6c980a1c..ae9df1b43 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -314,7 +314,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). With `path` the backend (the headless session or the app) writes the PNG there too. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | +| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, PSD/PSB, JPEG XL (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | | `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. | @@ -2353,7 +2353,7 @@ fills or strokes carry effects is written as one piece per fill and stroke, so o ## Glass textures Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or -`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD or PSB file, decoded within the pixel allowance the +`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD, PSB or JPEG XL file, decoded within the pixel allowance the renderer draws images in, counted as RGBA. `effect.apply` and `effect.setParams` store the image's gray levels in the effect as a grayscale PNG in base64 and set `texture: "loaded"` unless the params give a `texture`. `effect.list` reports the stored string, and a command given that string stores it unchanged. Any other string, a file that is not an image, or an image past @@ -2922,6 +2922,13 @@ flattened picture every Photoshop file carries (layers aren't read): Bitmap, Gra A document with transparency keeps it. `file.place {path}` links the file like any image (`links.update` reads it again when it changes); Multichannel files are refused. +JPEG XL images (`.jxl`, a bare codestream or the ISOBMFF container) open and place as their first frame, with the +orientation their header gives, decoded with jxl-oxide and stored as PNG. 16-bit and floating-point samples become 8 +bits, and alpha is kept. Colors are converted to sRGB in XYB-encoded images and when the header names the color space +or the embedded ICC profile describes one the decoder knows; other images with an embedded ICC profile keep their +samples as stored. CMYK files are refused, and an image past the pixel allowance is refused from its header, before +its pixels are decoded. `file.place {path}` links the file like any image. VectorCraft doesn't write JPEG XL. + ```json {"name":"export","arguments":{"path":"/tmp/poster.psd","options":{"ppi":300,"maxEditability":true}}} {"name":"run_command","arguments":{"command":"document.export","params":{"format":"psd","layers":false,"colorModel":"cmyk"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f666994a..8053b37a2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -607,6 +607,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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). + - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.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). @@ -663,7 +664,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it. -- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD or PSB file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, PSD, PSB or JPEG XL file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. - **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, Document Setup's substitution highlights, a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). From cbafe16b6109b4e292ce37b56080d84df9bddd05 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 10:22:14 -0700 Subject: [PATCH 02/36] Variables: a visibility variable shows and hides locked art variable.bind refused a locked layer, a locked object and objects inside a locked layer or group for every kind of variable, and dataset.select skipped them. A visibility variable bound before the art was locked stopped working, and a locked layer could not be shown or hidden by a data set. A visibility variable now binds locked art and objects inside a locked layer or group. dataset.select, dataset.next and dataset.prev show and hide them and leave them locked. dataset.list, dataset.capture and dataset.update read whether they show. Text, image and graph variables are refused for locked art as before, and a row skips them. Binding any variable still refuses art inside a hidden layer or group, and for a visibility variable the error says the art is in a hidden group or layer. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/variables.rs | 34 ++++++---- crates/engine/src/cmd/variablecmds.rs | 38 ++++++----- crates/engine/src/tests_variables.rs | 98 +++++++++++++++++++++++++-- docs/mcp.md | 12 ++-- docs/target-app-parity.md | 7 +- 5 files changed, 144 insertions(+), 45 deletions(-) diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs index 96bccbd0a..400cc4012 100644 --- a/crates/doc/src/variables.rs +++ b/crates/doc/src/variables.rs @@ -90,8 +90,9 @@ pub struct DataSet { impl DataSet { /// Whether the art, as [`Document::captured_values`] read it, still holds what this row - /// says. A value the art can't report (its object is locked or gone) isn't drift: the row - /// keeps it, and applying it again would skip it too. + /// says. A value the art can't report (its object is gone, or is locked or in a locked layer + /// or group and the variable isn't a visibility variable) isn't drift: the row keeps it, and + /// applying it again would skip it too. pub fn matches(&self, current: &BTreeMap) -> bool { self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) } @@ -188,26 +189,29 @@ impl Document { self.variables.bindings = bindings; } - /// Whether a variable may be bound to the object `id`: it is in the document, neither it nor - /// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't - /// count, as that is what a visibility variable drives. - pub fn variable_target(&self, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id)))) + /// Whether a variable of `kind` may be bound to the object `id`: it is in the document, + /// nothing above it is hidden, and, unless `kind` is visibility, neither it nor anything above + /// it is locked. Its own visibility doesn't count, as that is what a visibility variable + /// drives. + pub fn variable_target(&self, id: NodeId, kind: VariableKind) -> bool { + let locks = kind != VariableKind::Visibility; + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locks && n.locked) && (n.visible || *i == id)))) } - /// Whether a data set reads and rewrites the bound object `id`: it is in the document and - /// neither it nor anything above it is locked. Hidden objects count, and so do objects in a - /// hidden layer or group. - pub fn dataset_target(&self, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) + /// Whether a data set reads and rewrites the bound object `id` for a variable of `kind`: it + /// is in the document and, unless `kind` is visibility, neither it nor anything above it is + /// locked. Hidden objects count, and so do objects in a hidden layer or group. + pub fn dataset_target(&self, id: NodeId, kind: VariableKind) -> bool { + let locks = kind != VariableKind::Visibility; + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locks && n.locked)))) } /// What the object `id` holds for the variable of `kind`: its characters, whether it shows, /// or a linked image's path. `None` when the object is gone, is not of that kind, has no - /// link path yet (an embedded image), or is locked or in a locked layer or group (the - /// document's value is whatever it was left at). + /// link path yet (an embedded image), or, unless `kind` is visibility, is locked or in a + /// locked layer or group (the document's value is whatever it was left at). pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { - if !self.dataset_target(id) { + if !self.dataset_target(id, kind) { return None; } let node = self.node(id)?; diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index fb82f1f4b..c41c4f4d7 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -74,13 +74,14 @@ fn names_param(p: &Value, cmd: &str, fallback: Option, nothing: &str) -> } } -/// Refuse what a variable of `kind` can't be bound to: art that is locked or on a hidden or locked -/// layer; a text variable only drives type; an image variable only drives an image object. +/// Refuse what a variable of `kind` can't be bound to: art on a hidden layer or in a hidden group, +/// and, unless `kind` is visibility, art that is locked or on a locked layer or in a locked group; +/// a text variable only drives type; an image variable only drives an image object. fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> { for id in ids { match doc.node(*id) { None => return Err(EngineError::NoNode(*id)), - Some(_) if !doc.variable_target(*id) => return Err(bad(cmd, not_a_target(doc, *id))), + Some(_) if !doc.variable_target(*id, kind) => return Err(bad(cmd, not_a_target(doc, *id, kind))), Some(n) if kind == VariableKind::Text && !matches!(n.kind, NodeKind::Text(_)) => { return Err(bad(cmd, format!("node {} is not type", id.0))); } @@ -96,14 +97,17 @@ fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: Variabl Ok(()) } -/// Why `variable_target` refuses `id`: the object is locked, a group or layer that holds it is -/// locked, or one that holds it is hidden, checked in that order. -fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId) -> String { +/// Why `variable_target` refuses `id` for a variable of `kind`: the object is locked, a group or +/// layer that holds it is locked, or one that holds it is hidden, checked in that order. For a +/// visibility variable, the lock checks are skipped and the message says the object is in a +/// hidden group or layer. +fn not_a_target(doc: &vectorcraft_doc::Document, id: NodeId, kind: VariableKind) -> String { let holders: Vec<&vectorcraft_doc::Node> = doc.ancestry(id).unwrap_or_default().into_iter().filter(|i| *i != id).filter_map(|i| doc.node(i)).collect(); - if doc.node(id).is_some_and(|n| n.locked) { + let locks = kind != VariableKind::Visibility; + if locks && doc.node(id).is_some_and(|n| n.locked) { format!("node {} is locked", id.0) - } else if holders.iter().any(|n| n.locked) { + } else if locks && holders.iter().any(|n| n.locked) { format!("node {} is in a locked group or layer", id.0) } else { format!("node {} is in a hidden group or layer", id.0) @@ -395,8 +399,9 @@ fn dataset_capture(s: &mut Session, p: &Value) -> Result { } /// Update Data Set: write what the bound objects hold now into the row that is active, for each -/// variable the row holds. A variable whose objects report no value (locked, inside a locked -/// layer or group, or gone) keeps the row's value. With `all`, every other variable the +/// variable the row holds. A variable whose objects report no value (gone, or, for a variable +/// other than a visibility variable, locked or inside a locked layer or group) keeps the row's +/// value. With `all`, every other variable the /// objects report a value for is added to the row. Without an active row there is nothing to /// update, so say so rather than picking one. fn dataset_update(s: &mut Session, p: &Value) -> Result { @@ -480,10 +485,11 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { continue; } }; - // Locked art, art in a locked layer or group, and text whose object is gone, can't be - // rewritten: skipped and counted, like the rest. Hidden art is rewritten, in a hidden - // layer or group too, and stays hidden unless a visibility value shows it. - if !d.dataset_target(id) { + // Art that is gone, and art that is locked or in a locked layer or group, is skipped + // and counted, like the rest, except that a visibility value shows and hides locked + // art. Hidden art is rewritten, in a hidden layer or group too, and stays hidden + // unless a visibility value shows it. + if !d.dataset_target(id, kind) { skipped += 1; continue; } @@ -903,7 +909,7 @@ pub fn specs() -> Vec { "Bind Variable", ["Window", "Variables"], None, - "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art is refused. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", + "{variable, ids?} bind objects (default: the selection) to a variable; text needs type, image needs an image object, graph needs a graph root, locked art and art in a locked layer or group are refused except by a visibility variable. An object may hold several variables, and binding it to one it already has changes nothing → {bound, already}", has_doc, bind ), @@ -959,7 +965,7 @@ pub fn specs() -> Vec { "Update Data Set", ["Window", "Variables"], None, - "{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a locked layer or group, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}", + "{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (gone, or, for a variable other than a visibility variable, locked or inside a locked layer or group) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}", has_doc, dataset_update ), diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index b1d8d28d2..427272367 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -271,7 +271,7 @@ fn unbind_reports_what_it_actually_removed() { assert!(v["variables"].get("bindings").is_none_or(|v| v == &json!({})), "{v}"); } -/// Locked art is the document's to rewrite only while it isn't locked. +/// Locked type takes no new text variable binding, and a row leaves its text alone. #[test] fn locked_art_is_neither_bound_nor_rewritten() { let mut s = session(); @@ -327,6 +327,90 @@ fn binding_errors_say_whether_a_layer_or_group_is_hidden_or_locked() { assert!(e.contains(&format!("node {} is locked", t.0)), "{e}"); } +/// A visibility variable binds a locked layer and a locked object inside a locked group, and a row +/// hides and shows them in one undo step without unlocking them. List, Capture and Update Data Set +/// read whether they show. +#[test] +fn a_visibility_variable_shows_and_hides_locked_art() { + let mut s = session(); + let badge = rect(&mut s); + let other = rect(&mut s); + let g = group(&mut s, &[badge, other]); + let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); + rect(&mut s); + s.execute("object.setProps", &json!({"ids": [badge.0], "locked": true})).unwrap(); + s.execute("object.setProps", &json!({"ids": [g.0], "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap(); + s.execute("variable.define", &json!({"name": "Layer", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Badge", "kind": "visibility"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Layer", "ids": [layer.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Badge", "ids": [badge.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "Shown", "values": {"Layer": true, "Badge": true}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Hidden", "values": {"Layer": false, "Badge": false}})).unwrap(); + let shows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().visible; + let locked = |s: &Session| [layer, g, badge].iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().locked); + + let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}"); + assert!(!shows(&s, layer) && !shows(&s, badge), "the row hides the locked layer and the locked object"); + assert!(locked(&s), "the row leaves the locks as they were"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + let matching: Vec<&Value> = sets["datasets"].as_array().unwrap().iter().filter(|d| d["matches"] == json!(true)).map(|d| &d["name"]).collect(); + assert_eq!(matching, [&json!("Hidden")], "List reads the locked art: {sets}"); + assert_eq!(s.execute("dataset.capture", &json!({"name": "Captured"})).unwrap()["values"], 2); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][2]["values"], json!({"Badge": {"visible": false}, "Layer": {"visible": false}}), "{sets}"); + + // Undo puts back what the row hid. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(shows(&s, layer) && shows(&s, badge), "undo shows them again"); + + let v = s.execute("dataset.select", &json!({"name": "Hidden"})).unwrap(); + assert_eq!(v["applied"], 2, "{v}"); + let v = s.execute("dataset.select", &json!({"name": "Shown"})).unwrap(); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(2), Some(0)), "{v}"); + assert!(shows(&s, layer) && shows(&s, badge), "the row shows them"); + assert!(locked(&s)); + + // Update writes the locked layer's visibility into the active row. + s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap(); + let v = s.execute("dataset.update", &json!({})).unwrap(); + assert_eq!((v["values"].clone(), v["left"].clone()), (json!(2), json!([])), "{v}"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Badge": {"visible": true}, "Layer": {"visible": false}}), "{sets}"); +} + +/// Text and graph variables refuse locked art and art in a locked layer. A visibility variable is +/// refused for art on a hidden layer that is also locked, and the error says the art is in a hidden +/// group or layer. +#[test] +fn locked_art_refuses_text_and_graph_variables_and_a_hidden_layer_refuses_visibility() { + let mut s = session(); + let layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); + let t = text(&mut s, "x"); + let r = rect(&mut s); + s.execute("object.setProps", &json!({"ids": [r.0], "locked": true})).unwrap(); + s.execute("layer.setProps", &json!({"id": layer.0, "locked": true})).unwrap(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Chart", "kind": "graph"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + let bind = |s: &mut Session, var: &str, id: NodeId| s.execute("variable.bind", &json!({"variable": var, "ids": [id.0]})); + + let e = bind(&mut s, "Name", t).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} is in a locked group or layer", t.0)), "{e}"); + let e = bind(&mut s, "Chart", r).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} is locked", r.0)), "{e}"); + bind(&mut s, "Show", t).unwrap(); + assert!(s.doc().unwrap().doc.variables.objects_of("Name").is_empty()); + + s.execute("layer.setProps", &json!({"id": layer.0, "visible": false})).unwrap(); + let e = bind(&mut s, "Show", r).unwrap_err().to_string(); + assert!(e.contains(&format!("node {} is in a hidden group or layer", r.0)), "{e}"); + // The hidden layer's own visibility is what the variable drives. + bind(&mut s, "Show", layer).unwrap(); +} + /// Make Text Dynamic / Make Visibility Dynamic: what the panel's buttons run. One step from a /// selection to a bound variable, named after what it drives. #[test] @@ -531,7 +615,7 @@ fn updating_writes_only_the_variables_the_row_holds() { assert_eq!(sets["datasets"][0]["values"]["Client"], json!({"text": "Typed by hand"}), "{sets}"); } -/// A variable whose object is locked reports no value, so Update keeps the row's value for it +/// A text variable whose object is locked reports no value, so Update keeps the row's value for it /// rather than dropping it from the row. #[test] fn updating_keeps_a_value_the_art_does_not_report() { @@ -762,8 +846,8 @@ fn a_hidden_object_comes_back_and_is_captured_hidden() { } /// A row selected while a layer is hidden rewrites the type on it and applies its other values, -/// and leaves the layer hidden: showing the layer shows that row's text. A layer that is locked -/// as well as hidden is still left alone. +/// and leaves the layer hidden: showing the layer shows that row's text. On a layer that is locked +/// as well as hidden, the type keeps its text and the row's visibility value applies. #[test] fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() { let mut s = session(); @@ -791,12 +875,12 @@ fn a_row_rewrites_type_on_a_hidden_layer_but_not_on_a_locked_one() { s.execute("layer.setProps", &json!({"id": layer.0, "visible": true})).unwrap(); assert_eq!(plain(&s, t), "Bob", "showing the layer shows the row's text"); - // Locked and hidden: the row skips both objects and the type keeps its text. + // Locked and hidden: the row skips the type, which keeps its text, and shows the rectangle. s.execute("layer.setProps", &json!({"id": layer.0, "visible": false, "locked": true})).unwrap(); let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap(); - assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(0), Some(2)), "{v}"); + assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64()), (Some(1), Some(1)), "{v}"); assert_eq!(plain(&s, t), "Bob", "the type on the locked layer keeps its text"); - assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + assert!(s.doc().unwrap().doc.node(r).unwrap().visible, "the row's visibility value applies on the locked layer"); } /// Type inside a hidden group is rewritten too, and List and Capture Data Set read it. diff --git a/docs/mcp.md b/docs/mcp.md index c49b9e6e3..2b89e187a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1156,11 +1156,15 @@ already walks Image Trace. Short agent entry points: `dataset.select`, `dataset.next` and `dataset.prev` reply `{name, applied, skipped}`. They add `overflow: [id…]` when area type or type on a path whose text they rewrote doesn't fit, as `text.setText` does (see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). They skip an object that is -locked or inside a locked layer or group, and count it in `skipped`. They rewrite hidden objects and objects inside +locked or inside a locked layer or group, and count it in `skipped`, unless the value is a visibility value, which +shows or hides it. They rewrite hidden objects and objects inside a hidden layer or group, which stay hidden unless one of the data set's visibility values shows them. When such a layer is shown again, its type holds the data set's text. They apply the values in the order the variables are defined. Of two variables of the same kind on one object, the one defined last wins. `dataset.list`, -`dataset.capture` and `dataset.update` read hidden objects and objects inside a hidden layer or group as well. +`dataset.capture` and `dataset.update` read hidden objects and objects inside a hidden layer or group as well, and +read whether a locked object, or one inside a locked layer or group, shows. `variable.bind` binds a visibility +variable to a locked layer or object and to objects inside a locked layer or group, and refuses them for a text, +image or graph variable. ```json {"name":"run_command","arguments":{"command":"symbol.list","params":{}}} @@ -1174,8 +1178,8 @@ defined. Of two variables of the same kind on one object, the one defined last w ``` `dataset.update {all?}` writes what the bound objects hold now into the active data set, for each variable that the -data set holds. A variable whose objects report no value, because they are locked, inside a locked layer or group, -or deleted, keeps the data set's value. With `all: true` it also adds every other variable that the +data set holds. A variable whose objects report no value, because they are deleted or, for a text, image or graph +variable, locked or inside a locked layer or group, keeps the data set's value. With `all: true` it also adds every other variable that the objects report a value for. The reply gives `values`, the number of values it wrote, and `left`, the variables it did not write. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 70a212814..15fff3a55 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -423,13 +423,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** - - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped. + - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. + - Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value. + - Variables: a visibility variable binds a locked layer, a locked object and objects inside a locked layer or group, and `dataset.select`, `dataset.next` and `dataset.prev` show and hide them without unlocking them; `dataset.list`, `dataset.capture` and `dataset.update` read whether they show. A text, image or graph variable refuses locked art. - 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. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\` and `:` in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - - 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 (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 except by visibility values; 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). From dfb882da552a73497171f10cef76d8ddca016104 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 13:58:28 -0700 Subject: [PATCH 03/36] File formats: count JPEG XL beside Targa, and translate its messages in ka and zh-hant docs/file-format-parity.md counts 26 formats opened or placed, Targa and JPEG XL among them. clipboard.importImage's params text names Targa, which main reads by its header, beside JPEG XL. The Georgian and Traditional Chinese catalogs, which now translate every status and error message, get the three JPEG XL messages. --- crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/ui-egui/src/i18n/ka.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ docs/file-format-parity.md | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 8cc18ac63..725b8c5d9 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -166,7 +166,7 @@ pub fn specs() -> Vec { "Load Image into Clipboard", [], None, - "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, PSD, PSB or JPEG XL bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste", + "{dataBase64 (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB or JPEG XL bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste", has_doc, import_image ), diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 22f4a036d..0bc503c1b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4132,6 +4132,9 @@ effect Regular ჩვეულებრივი @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 the JPEG XL file is cut short JPEG XL ფაილი შეკვეცილია +@msg CMYK JPEG XL images can't be read CMYK JPEG XL გამოსახულებების წაკითხვა ვერ ხერხდება +@msg the JPEG XL file can't be read: {e} JPEG XL ფაილის წაკითხვა ვერ ხერხდება: {e} @msg Imported library {name} შემოტანილია ბიბლიოთეკა {name} @msg Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგენილია ნაგულისხმევი დოკი @msg action is required action აუცილებელია diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f0e1b271c..9c356c2dd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2198,6 +2198,9 @@ file-filter Templates 範本 @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 the JPEG XL file is cut short JPEG XL 檔案不完整 +@msg CMYK JPEG XL images can't be read 無法讀取 CMYK 模式的 JPEG XL 影像 +@msg the JPEG XL file can't be read: {e} 無法讀取 JPEG XL 檔案:{e} @msg Imported library {name} 已匯入資料庫 {name} @msg invalid restored panel group 還原的面板群組無效 @msg Invalid panel layout; restored the default dock 面板配置無效;已還原預設停駐區 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ae524ca23..de67cd2c2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| -| Formats opened or placed | ~30 | 25 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word `.doc`, PS | +| Formats opened or placed | ~30 | 26 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word `.doc`, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) | | 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 | From 7c513f3f3a85a0739c2addcb312caae94bc72e1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 16:39:02 -0700 Subject: [PATCH 04/36] File formats: a JPEG XL image whose decoder buffers run out is too large to draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decoder's pixel buffers are capped by the image's size and the raster allowance. When they need more, File › Open and Place now say the image is too large to draw, as they do when its size alone is over the allowance, instead of saying the file can't be read. Damaged and cut-short files keep their messages. --- Cargo.lock | 1 + Cargo.toml | 2 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/fileio/jxlread.rs | 39 ++++++++++++++----- crates/engine/src/cmd/fileio/tests_jxlread.rs | 17 +++++++- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d886823e..431791145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4839,6 +4839,7 @@ dependencies = [ "flate2", "image", "jxl-color", + "jxl-grid", "jxl-oxide", "kurbo 0.13.1", "log", diff --git a/Cargo.toml b/Cargo.toml index 6fcec350e..496c5de94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,8 @@ zune-jpeg = "0.5" jxl-oxide = { version = "0.12.6", default-features = false } # jxl-oxide's color conversions, run on colors after their alpha is divided out. jxl-color = "0.11.0" +# jxl-oxide's buffer allocation error, told apart from other decoding errors. +jxl-grid = "0.6.2" # Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files. zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] } zune-core = "0.5" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 36557e46f..e87992f44 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -38,6 +38,7 @@ vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } jxl-oxide = { workspace = true } jxl-color = { workspace = true } +jxl-grid = { workspace = true } flate2 = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). roxmltree = "0.21" diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 1826c43df..5c6856ffa 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -39,16 +39,17 @@ pub fn is_jxl(bytes: &[u8]) -> bool { } /// The first frame of a JPEG XL image as straight RGBA. Fails, without decoding the pixels, when -/// the image would take more than `max_alloc` bytes as RGBA. +/// the image would take more than `max_alloc` bytes as RGBA. The image is also too large to draw +/// when the decoder's buffers for its pixels need more than they are allowed. pub fn decode(bytes: &[u8], max_alloc: u64) -> Result { let pixel_boxes = pixel_boxes(bytes); let bytes = pixel_boxes.as_deref().unwrap_or(bytes); // The decoder reads the header first, with buffers for the header alone. - let (head, _) = header(bytes, HEADER_BUFFERS)?; + let (head, _) = header(bytes, HEADER_BUFFERS, None)?; let (w, h) = (head.width(), head.height()); let rgba = u64::from(w) * u64::from(h) * 4; if rgba > max_alloc { - return Err(format!("an image of {w} × {h} pixels is too large to draw")); + return Err(too_large(w, h)); } if head.pixel_format().has_black() { return Err("CMYK JPEG XL images can't be read".into()); @@ -56,14 +57,15 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result // Then it reads the file again until the first frame is loaded, with buffers for the pixels. let budget = rgba.saturating_mul(DECODER_BUFFERS).saturating_add(HEADER_BUFFERS as u64).min(max_alloc); let budget = usize::try_from(budget).unwrap_or(usize::MAX); - let (mut image, mut at) = header(bytes, budget)?; + let size = Some((w, h)); + let (mut image, mut at) = header(bytes, budget, size)?; let mut end = at; while image.num_loaded_keyframes() == 0 { if end == bytes.len() { return Err(TRUNCATED.into()); } end = end.saturating_mul(2).max(HEAD).min(bytes.len()); - at += image.feed_bytes(&bytes[at..end]).map_err(unreadable)?; + at += image.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?; } let metadata = &image.image_header().metadata; let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true); @@ -92,7 +94,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result if let Some(encoding) = request { image.request_color_encoding(encoding); } - let render = image.render_frame(0).map_err(unreadable)?; + let render = image.render_frame(0).map_err(|e| failed(e, size))?; let mut stream = render.stream(); let channels = stream.channels() as usize; if !(1..=4).contains(&channels) { @@ -191,14 +193,15 @@ fn pixel_boxes(bytes: &[u8]) -> Option> { } /// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read -/// the header → (the decoder, how many bytes it took). -fn header(bytes: &[u8], buffers: usize) -> Result<(JxlImage, usize), String> { +/// the header → (the decoder, how many bytes it took). `size` is the image's size when it is +/// known, for the error when the buffers need more ([`failed`]). +fn header(bytes: &[u8], buffers: usize, size: Option<(u32, u32)>) -> Result<(JxlImage, usize), String> { let mut uninit = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(buffers)).build_uninit(); let (mut at, mut end) = (0, 0usize); loop { end = end.saturating_mul(2).max(HEAD).min(bytes.len()); - at += uninit.feed_bytes(&bytes[at..end]).map_err(unreadable)?; - match uninit.try_init().map_err(unreadable)? { + at += uninit.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?; + match uninit.try_init().map_err(|e| failed(e, size))? { InitializeResult::Initialized(image) => return Ok((image, at)), InitializeResult::NeedMoreData(_) if end == bytes.len() => return Err(TRUNCATED.into()), InitializeResult::NeedMoreData(more) => uninit = more, @@ -213,6 +216,22 @@ fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool { xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty())) } +/// The decoder's error `e` as a message. When the decoder's buffers needed more than they were +/// allowed and the image's `size` is known, the image is too large to draw. Otherwise the file +/// can't be read. +fn failed(e: Box, size: Option<(u32, u32)>) -> String { + let first: &(dyn std::error::Error + 'static) = &*e; + let out_of_memory = std::iter::successors(Some(first), |c| c.source()).any(|c| c.is::()); + match size { + Some((w, h)) if out_of_memory => too_large(w, h), + _ => unreadable(e), + } +} + +fn too_large(w: u32, h: u32) -> String { + format!("an image of {w} × {h} pixels is too large to draw") +} + fn unreadable(e: impl std::fmt::Display) -> String { format!("the JPEG XL file can't be read: {e}") } diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index e4d3a1aad..f3768c003 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -127,12 +127,25 @@ fn an_image_too_large_is_refused_from_its_header() { let mut s = Session::new(); let e = s.execute("document.open", &json!({"name": "huge.jxl", "dataBase64": b64(&huge)})).unwrap_err().to_string(); assert!(e.contains("too large to draw"), "{e}"); - // The allowance counts RGBA bytes: 3 × 2 pixels take 24 (the decoder's own buffers then need more). + // The allowance counts RGBA bytes: 3 × 2 pixels take 24. assert!(decode(&rgba8(), 23).unwrap_err().contains("too large")); - assert!(!decode(&rgba8(), 24).unwrap_err().contains("too large")); assert!(decode(&rgba8(), 1 << 20).is_ok()); } +/// The decoder's buffers take more than the image as RGBA. When they need more than the allowance, +/// the image is too large to draw, as when its size alone is. +#[test] +fn an_image_whose_decoder_buffers_pass_the_allowance_is_too_large() { + assert_eq!(decode(&rgba8(), 24).unwrap_err(), "an image of 3 × 2 pixels is too large to draw"); + let gray: Vec = (0..64 * 64).map(|i| (i % 251) as u8).collect(); + let f = container(&jxl(64, 64, ColorSpace::Luma, BitDepth::Eight, &gray)); + assert_eq!(decode(&f, 64 * 64 * 4).unwrap_err(), "an image of 64 × 64 pixels is too large to draw"); + assert!(decode(&f, 1 << 20).is_ok()); + // A file cut short is still reported as cut short or unreadable. + let e = decode(&f[..f.len() - 3], 1 << 20).unwrap_err(); + assert!(!e.contains("too large"), "{e}"); +} + /// Premultiplied colors are divided by their alpha before they are converted to sRGB: a linear /// 0.25 at alpha 0.5 is a straight linear 0.5, which sRGB stores as 0.735 (188 of 255). #[test] From f2e2a5b5f9360a8cb468f478e5b6ec8ebec9879b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 21:29:52 -0700 Subject: [PATCH 05/36] File formats: a JPEG XL image keeps the RGB ICC profile its colors stay in When a JPEG XL image embeds an ICC profile that the decoder can't convert to sRGB without a color management system, its colors come back as stored. The PNG the image was stored as left the profile out, so a saved document or an SVG export held those colors without their color space. That PNG now carries the profile when it is an RGB one. Colors converted to sRGB are stored without a profile, as before, and so are a gray image's colors, which come back as RGB. --- crates/engine/src/cmd/fileio/jxlread.rs | 22 ++++++--- crates/engine/src/cmd/fileio/load.rs | 21 ++++++--- crates/engine/src/cmd/fileio/tests_jxlread.rs | 46 +++++++++++++++++-- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 5c6856ffa..021cd5909 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -5,7 +5,8 @@ //! Colors come back as sRGB whenever the decoder converts them without a color management //! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC //! profiles that describe one of them. Any other image with an embedded ICC profile comes back -//! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and with +//! the profile when it is an RGB one. 16-bit and floating-point samples become 8 bits, alpha comes back straight, //! and CMYK images are refused. The file is untrusted: the decoder is given only a container's //! image boxes, the image's size is checked from its header before any pixels are decoded, and the //! decoder's image buffers are capped. @@ -38,10 +39,11 @@ pub fn is_jxl(bytes: &[u8]) -> bool { bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER) } -/// The first frame of a JPEG XL image as straight RGBA. Fails, without decoding the pixels, when -/// the image would take more than `max_alloc` bytes as RGBA. The image is also too large to draw -/// when the decoder's buffers for its pixels need more than they are allowed. -pub fn decode(bytes: &[u8], max_alloc: u64) -> Result { +/// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors +/// are in when they are not converted to sRGB ([`kept_profile`]). Fails, without decoding the +/// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too +/// large to draw when the decoder's buffers for its pixels need more than they are allowed. +pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), String> { let pixel_boxes = pixel_boxes(bytes); let bytes = pixel_boxes.as_deref().unwrap_or(bytes); // The decoder reads the header first, with buffers for the header alone. @@ -71,6 +73,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true); let intent = RenderingIntent::Relative; let srgb = if metadata.grayscale() { EnumColourEncoding::gray_srgb(intent) } else { EnumColourEncoding::srgb(intent) }; + let profile = kept_profile(metadata.xyb_encoded, image.original_icc()); // Premultiplied colors are divided by alpha in the encoding they are stored in, and converted // to sRGB after that. XYB-encoded images come from the decoder in linear sRGB (tone mapped when // they are HDR), which the sRGB transfer curve alone turns into sRGB. @@ -110,7 +113,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result } to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?; } - Ok(out) + Ok((out, profile)) } /// How colors whose alpha was divided out become sRGB. @@ -216,6 +219,13 @@ fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool { xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty())) } +/// The embedded ICC profile `icc` when the colors come back as stored in it +/// ([`to_srgb_without_cms`] is false), for the PNG they are kept in. Only an RGB profile is kept: +/// the colors of a gray image come back as RGB. +pub(crate) fn kept_profile(xyb: bool, icc: Option<&[u8]>) -> Option> { + icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !to_srgb_without_cms(xyb, Some(icc))).map(<[u8]>::to_vec) +} + /// The decoder's error `e` as a message. When the decoder's buffers needed more than they were /// allowed and the image's `size` is known, the image is too large to draw. Otherwise the file /// can't be read. diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 748843179..c32fec37f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -509,7 +509,7 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option { /// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw. /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are -/// read with [`jxlread`] and stored as PNG. +/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -517,10 +517,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { } 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); + return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, None, ppi); } if jxlread::is_jxl(bytes) { - return as_png(jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + let (img, icc) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; + return as_png(img, icc, ppi); } let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; if reader.format().is_none() && super::tgaread::is_targa(bytes) { @@ -563,14 +564,20 @@ pub fn raster_image(bytes: &[u8]) -> Result { if f.id == "tga" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, ppi) + as_png(img, None, ppi) } -/// `img` stored as a PNG that keeps the file's resolution. -fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result { +/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its +/// colors are in. +pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { + use image::ImageEncoder as _; let size = img.dimensions(); let mut png = Vec::new(); - img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + encoder.set_icc_profile(icc).map_err(err)?; + } + img.write_with_encoder(encoder).map_err(err)?; let png = match ppi { Some(r) => super::ppi::with_png_resolution(&png, r), None => png, diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index f3768c003..1c248f486 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -6,7 +6,7 @@ use zune_core::bit_depth::BitDepth; use zune_core::colorspace::ColorSpace; use zune_core::options::EncoderOptions; -use super::jxlread::{ToSrgb, decode, is_jxl, to_rgba}; +use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -91,7 +91,7 @@ fn sixteen_bit_samples_become_eight_bits() { let f = jxl(2, 2, ColorSpace::RGB, BitDepth::Sixteen, &samples); let to8 = |v: u16| (f64::from(v) / 65535.0 * 255.0).round() as u8; let want: Vec<[u8; 4]> = rgb.iter().map(|p| [to8(p[0]), to8(p[1]), to8(p[2]), 255]).collect(); - assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::>(), want); + assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::>(), want); // Gray, 16 bits, in the container, opened as a document. let gray: Vec = [0xffffu16, 0x8080, 0x0000, 0x4040].iter().flat_map(|v| v.to_ne_bytes()).collect(); let f = container(&jxl(2, 2, ColorSpace::Luma, BitDepth::Sixteen, &gray)); @@ -202,7 +202,47 @@ fn metadata_boxes_are_not_read() { boxed(b"Exif", &[0, 0, 0, 0, b'M', b'M', 0, 42]); boxed(b"brob", &[b'x', b'm', b'l', b' ', 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); boxed(b"jxlc", &rgba8()); - assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::>(), RGBA); + assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::>(), RGBA); +} + +/// Colors left in their embedded RGB profile keep it: the PNG they are stored in carries the +/// profile. Colors converted to sRGB, and a gray image's colors, which come back as RGB, are stored +/// without one. +#[test] +fn colors_left_in_their_icc_profile_keep_it() { + use image::ImageDecoder as _; + use jxl_oxide::{EnumColourEncoding, RenderingIntent}; + // A profile jxl-color can't turn into a JPEG XL color space, so the decoder leaves the colors + // in it: the sRGB profile with the tag of its red curve renamed. + let unnamed = |encoding: &EnumColourEncoding, curve: &[u8; 4]| { + let mut icc = jxl_color::icc::colour_encoding_to_icc(encoding); + let count = u32::from_be_bytes(icc[128..132].try_into().unwrap()) as usize; + for at in (0..count).map(|i| 132 + 12 * i) { + if icc[at..at + 4] == curve[..] { + icc[at..at + 4].copy_from_slice(b"xTRC"); + } + } + icc + }; + let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative); + let rgb = unnamed(&srgb, b"rTRC"); + assert_ne!(rgb, jxl_color::icc::colour_encoding_to_icc(&srgb), "a tag renamed"); + assert_eq!(kept_profile(false, Some(&rgb)), Some(rgb.clone())); + assert_eq!(kept_profile(true, Some(&rgb)), None, "XYB-encoded colors become sRGB"); + assert_eq!(kept_profile(false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))), None, "converted to sRGB"); + let gray = unnamed(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative), b"kTRC"); + assert_eq!(kept_profile(false, Some(&gray)), None, "gray"); + assert_eq!(kept_profile(false, None), None); + // The PNG carries the profile, the pixels and the resolution. + let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap(); + for icc in [Some(rgb), None] { + let RasterImage { blob, width, height, .. } = super::load::as_png(img.clone(), icc.clone(), Some((144.0, 144.0))).unwrap(); + assert_eq!((width, height), (2, 1)); + let mut png = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&blob.bytes[..])).unwrap(); + assert_eq!(png.icc_profile().unwrap(), icc); + assert_eq!(image::DynamicImage::from_decoder(png).unwrap().to_rgba8(), img); + assert_eq!(super::ppi::resolution(&blob.bytes).map(|(x, y)| (x.round(), y.round())), Some((144.0, 144.0))); + } } #[test] From 90788b632afd76f9e3232346f0a62f8b0928a3cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 09:50:51 -0700 Subject: [PATCH 06/36] Linux: VectorCraft draws its own title bar on every desktop unless System Title Bar is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers the window kept the desktop's frame, and the app bar with its brand mark sat below it. Where the desktop draws a title bar, as KDE Plasma does, the window showed two. When the desktop's bar was hidden through the window manager, the app bar left behind did not move the window and had no caption buttons. On Linux and the BSDs VectorCraft now draws its own title bar on every desktop unless Preferences › User Interface › System Title Bar is on, as PhotoCraft does. The craft-window probe no longer picks the frame. VECTORCRAFT_TITLEBAR decides for the run it is set in: app draws VectorCraft's bar and system or desktop keeps the desktop's frame, also when the preference says otherwise. Before, System Title Bar on gave the desktop's frame even with app. macOS and Windows are unchanged. When the variable keeps the desktop's frame, the app bar leaves out the brand mark, as it does with System Title Bar. While the variable is set, Preferences dims System Title Bar and says that the variable chooses the title bar; the dialog's __titlebarFromEnv field is true. The note has a row in the 24 catalogs. Docs: docs/control-protocol.md, docs/development.md, docs/target-app-parity.md. --- apps/vectorcraft/src/main.rs | 126 +++++++++++++--------------- crates/ui-egui/src/chrome.rs | 24 ++++-- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.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/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.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/tr.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 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/prefs_dialog.rs | 70 +++++++++++++++- docs/control-protocol.md | 6 +- docs/development.md | 2 +- docs/target-app-parity.md | 1 + 31 files changed, 178 insertions(+), 79 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index ee4232dc3..a92cda204 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -705,11 +705,11 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type) } -/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). +/// Windows: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). /// macOS keeps its traffic lights over the integrated title strip. /// -/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the -/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe. +/// Used on macOS and Windows; Linux and the BSDs decide in [`custom_titlebar_from`], where +/// VectorCraft also draws its own title bar by default. #[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); @@ -720,60 +720,42 @@ fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) } -/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate -/// decided and the user's System Title Bar preference. +/// Whether this start draws its own title bar, given `VECTORCRAFT_TITLEBAR` (`overridden`, as +/// [`craft_window::Choice::overridden`] reads it) and the user's System Title Bar preference. /// -/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame -/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with -/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it -/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), -/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly -/// when `craft-window` says the app owns the frame **and** the user has not opted into the system -/// title bar; the preference always forces the native frame back on. -/// -/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the -/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but -/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons -/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not -/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe. +/// Linux and the BSDs: VectorCraft draws its own title bar on every desktop (GNOME, KDE, Sway, +/// Hyprland, COSMIC, X11 window managers) unless System Title Bar is on, as PhotoCraft does. The +/// variable decides for one run when it is set: `app` draws VectorCraft's bar and `system` or +/// `desktop` keeps the desktop's frame, whatever the preference says. The desktop the +/// `craft-window` probe finds does not change the result. #[cfg(not(any(target_os = "macos", target_os = "windows")))] -fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool { - matches!(frame, craft_window::Frame::App(_)) && !opted_into_system +fn custom_titlebar_from(overridden: Option, opted_into_system: bool) -> bool { + overridden.unwrap_or(!opted_into_system) } -/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see -/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`); -/// Windows draws its own caption unless the user opted into the system title bar. +/// macOS and Windows: decided by [`CUSTOM_TITLEBAR`] and the preference; `VECTORCRAFT_TITLEBAR` +/// is not read. macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`); Windows draws its +/// own caption unless the user opted into the system title bar. #[cfg(any(target_os = "macos", target_os = "windows"))] -fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool { +fn custom_titlebar_from(_overridden: Option, opted_into_system: bool) -> bool { CUSTOM_TITLEBAR && !opted_into_system } -/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its -/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System -/// Title Bar preference. Call once at startup, before [`native_options`]. -/// -/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through -/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs -/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native -/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. -fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); - let opted_into_system = opted_into_system_title_bar(saved); - #[cfg(target_os = "linux")] - { - let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); - // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window - // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are - // present, the same two conditions the launch path checks below — that run is effectively - // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so - // the probe's own decision (app-drawn on GNOME) must stand. - let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); - if choice.overridden().is_none() && forced_to_x11 { - return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); - } +/// What `VECTORCRAFT_TITLEBAR` asks for on Linux and the BSDs: `Some(true)` VectorCraft's own title +/// bar, `Some(false)` the desktop's frame, `None` when it is unset or holds another value. Always +/// `None` on macOS and Windows. The `craft-window` probe runs once per process. +fn titlebar_override() -> Option { + static OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); + if cfg!(any(target_os = "macos", target_os = "windows")) { + return None; } - custom_titlebar_from(choice.frame(), opted_into_system) + *OVERRIDE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")).overridden()) +} + +/// Whether this start draws its own title bar: [`custom_titlebar_from`] with `VECTORCRAFT_TITLEBAR` +/// and the saved System Title Bar preference. Call once at startup, before [`native_options`]. +fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + custom_titlebar_from(titlebar_override(), opted_into_system_title_bar(saved)) } fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { @@ -1128,6 +1110,7 @@ fn main() -> std::process::ExitCode { app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into()); } app.custom_titlebar = custom_titlebar; + app.titlebar_from_env = titlebar_override().is_some(); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); @@ -1314,8 +1297,6 @@ mod tests { #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { - use craft_window::{Frame, Layout}; - // The System Title Bar preference (Preferences › User Interface) is read the same way on // every platform; a missing or unreadable value is `false`. assert!(!super::opted_into_system_title_bar(None), "no preferences: the default"); @@ -1324,33 +1305,40 @@ mod tests { let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); assert!(super::opted_into_system_title_bar(Some(&on))); - // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title - // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native - // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws - // our own title bar — unless the user opted into the system title bar, which forces the - // native frame back on. - let app = Frame::App(Layout::default()); - let desktop = Frame::Desktop; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own"); - assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native"); - assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame"); - assert!(!super::custom_titlebar_from(&desktop, true)); - } - // macOS and Windows keep today's behavior, independent of the probed frame: the pref still - // forces the native frame back on where a custom one would otherwise be drawn. + // macOS and Windows: `VECTORCRAFT_TITLEBAR` is not read, and the preference turns the + // native frame back on where VectorCraft would draw its own. #[cfg(any(target_os = "macos", target_os = "windows"))] { - assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR); - assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows"); - assert!(!super::custom_titlebar_from(&desktop, true)); + assert_eq!(super::titlebar_override(), None); + for overridden in [None, Some(true), Some(false)] { + assert_eq!(super::custom_titlebar_from(overridden, false), super::CUSTOM_TITLEBAR, "{overridden:?}"); + assert!(!super::custom_titlebar_from(overridden, true), "{overridden:?}"); + } } assert_eq!(super::native_options(false).viewport.decorations, Some(true)); assert_eq!(super::native_options(true).viewport.decorations, Some(false)); } + /// Linux and the BSDs: VectorCraft's own title bar on every desktop unless System Title Bar is + /// on, and `VECTORCRAFT_TITLEBAR` decides over the preference for the run it is set in. + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[test] + fn every_linux_desktop_gets_the_app_title_bar_unless_system_title_bar_is_on() { + // What the `craft-window` probe reports: the desktop's frame on KDE Plasma, Sway, Hyprland, + // COSMIC and X11, the app's on GNOME's Wayland session; none of them sets the override. + for desktop in ["GNOME Wayland", "KDE Plasma", "Sway", "Hyprland", "COSMIC", "X11 window manager", "GNOME on X11"] { + assert!(super::custom_titlebar_from(None, false), "{desktop}: VectorCraft's own title bar"); + assert!(!super::custom_titlebar_from(None, true), "{desktop}: System Title Bar keeps the desktop's frame"); + } + // VECTORCRAFT_TITLEBAR=app draws VectorCraft's bar and =system (or =desktop) keeps the + // desktop's frame, whatever the preference says. + for opted_into_system in [false, true] { + assert!(super::custom_titlebar_from(Some(true), opted_into_system), "app, preference {opted_into_system}"); + assert!(!super::custom_titlebar_from(Some(false), opted_into_system), "system, preference {opted_into_system}"); + } + } + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5eeda2590..e8340df52 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -111,12 +111,13 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } /// Whether the window shows the system's title bar instead of the app drawing its own: Windows -/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system -/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor -/// does the web build (the browser tab has no document title), nor an embedder that never -/// turned the preference on. +/// and Linux with Preferences › User Interface › System Title Bar, and Linux when +/// `VECTORCRAFT_TITLEBAR` kept the desktop's frame ([`VectorcraftApp::titlebar_from_env`]). macOS +/// always has system decorations, but keeps the in-app brand mark, so it never counts as system +/// mode here; nor does the web build (the browser tab has no document title), nor an embedder +/// that never turned the preference on. pub fn system_title_bar(app: &VectorcraftApp) -> bool { - !app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) + !app.custom_titlebar && (app.session.prefs.system_title_bar || app.titlebar_from_env) && !cfg!(any(target_os = "macos", target_arch = "wasm32")) } /// The OS window title: the active document's name suffixed with the app name (a `*` prefix @@ -2216,6 +2217,19 @@ mod tests { } } + #[test] + fn a_desktop_frame_from_the_environment_variable_is_system_mode() { + // VECTORCRAFT_TITLEBAR=system with System Title Bar off: the desktop draws the title bar. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.titlebar_from_env = true; + assert!(!app.session.prefs.system_title_bar); + assert_eq!(super::system_title_bar(&app), !cfg!(target_os = "macos")); + // VECTORCRAFT_TITLEBAR=app: VectorCraft draws it, whatever the preference says. + app.custom_titlebar = true; + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + } + #[test] fn sync_window_title_sends_only_on_change() { let mut app = title_test_app(true); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f4f5b1fc1..197ba640b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -190,6 +190,7 @@ font-technology Variable Променливи Appearance panel Панел „Външен вид“ Applies when the linked file is relinked or updated. Прилага се, когато свързаният файл бъде пресвързан или актуализиран. Applies the next time VectorCraft starts. Прилага се при следващото стартиране на VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Докато е зададена, променливата на средата VECTORCRAFT_TITLEBAR избира заглавната лента. Apply Прилагане Apply Brush Прилагане на четка Apply Character Style Прилагане на стил на знаци diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 07c73f238..094e937d5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3890,6 +3890,7 @@ font-technology Variable Variabilní Panel menu Nabídka panelu Unembed… Zrušit vložení… Applies the next time VectorCraft starts. Projeví se při příštím spuštění VectorCraftu. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dokud je proměnná prostředí VECTORCRAFT_TITLEBAR nastavena, záhlaví okna volí ona. Enable Contextual Task Bar Povolit kontextový panel úloh Next Další OK OK diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 994f115ab..b0bdbb99f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3633,6 +3633,7 @@ font-technology Variable Variable Panel menu Panelmenu Unembed… Ophæv integrering… Applies the next time VectorCraft starts. Anvendes næste gang VectorCraft starter. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljøvariablen VECTORCRAFT_TITLEBAR vælger titellinjen, så længe den er angivet. Enable Contextual Task Bar Aktivér kontekstuel opgavelinje Next Næste OK OK diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a3534cce1..88ede5921 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -179,6 +179,7 @@ font-technology Variable Variabel Appearance panel Bedienfeld „Aussehen“ Applies when the linked file is relinked or updated. Gilt, wenn die verknüpfte Datei neu verknüpft oder aktualisiert wird. Applies the next time VectorCraft starts. Gilt ab dem nächsten Start von VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Solange die Umgebungsvariable VECTORCRAFT_TITLEBAR gesetzt ist, bestimmt sie die Titelleiste. Apply Anwenden Apply Brush Pinsel anwenden Apply Character Style Zeichenformat anwenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f537725c0..aa1bb7452 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -183,6 +183,7 @@ font-technology Variable Μεταβλητές Appearance Panel Πίνακας εμφάνισης Appearance panel Πίνακας εμφάνισης Applies the next time VectorCraft starts. Εφαρμόζεται στην επόμενη εκκίνηση του VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου. Apply Εφαρμογή Apply Brush Εφαρμογή πινέλου Apply Character Style Εφαρμογή στυλ χαρακτήρων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index da1f4b362..86bab2cf7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -204,6 +204,7 @@ font-technology Variable Variables Appearance panel Panel Apariencia Applies when the linked file is relinked or updated. Se aplica al volver a vincular o actualizar el archivo vinculado. Applies the next time VectorCraft starts. Se aplica la próxima vez que se inicie VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mientras está definida, la variable de entorno VECTORCRAFT_TITLEBAR elige la barra de título. Apply Aplicar Apply Brush Aplicar pincel Apply Character Style Aplicar estilo de carácter diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ebb354f9..b3b5eac8f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3814,6 +3814,7 @@ font-technology Variable Muuttuvat Panel menu Paneelivalikko Unembed… Poista upotus… Applies the next time VectorCraft starts. Tulee voimaan, kun VectorCraft käynnistetään seuraavan kerran. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Ympäristömuuttuja VECTORCRAFT_TITLEBAR valitsee otsikkorivin niin kauan kuin se on asetettu. Enable Contextual Task Bar Ota kontekstuaalinen tehtäväpalkki käyttöön Next Seuraava OK OK diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 943ca1140..9e9d6f113 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -205,6 +205,7 @@ font-technology Variable Variables Appearance panel Panneau Aspect Applies when the linked file is relinked or updated. S’applique lorsque le fichier lié est de nouveau lié ou mis à jour. Applies the next time VectorCraft starts. S’applique au prochain démarrage de VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Tant qu’elle est définie, la variable d’environnement VECTORCRAFT_TITLEBAR choisit la barre de titre. Apply Appliquer Apply Brush Appliquer la forme Apply Character Style Appliquer le style de caractère diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 670c93a99..7c8050626 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -286,6 +286,7 @@ font-technology Variable Változó Appearance panel Megjelenés panel Appearance Panel Megjelenés panel Applies the next time VectorCraft starts. A VectorCraft következő indításakor lép életbe. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Amíg a VECTORCRAFT_TITLEBAR környezeti változó be van állítva, az választja ki a címsort. Applies when the linked file is relinked or updated. A csatolt fájl újracsatolásakor vagy frissítésekor érvényes. Apply Alkalmaz Apply Brush Ecset alkalmazása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c91631178..cb01172bd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3633,6 +3633,7 @@ font-technology Variable Variabel Panel menu Menu panel Unembed… Lepaskan sematan… Applies the next time VectorCraft starts. Berlaku saat VectorCraft dimulai berikutnya. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Selama ditetapkan, variabel lingkungan VECTORCRAFT_TITLEBAR memilih bilah judul. Enable Contextual Task Bar Aktifkan bilah tugas kontekstual Next Berikutnya OK OK diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf329b283..8fca3e13e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -204,6 +204,7 @@ font-technology Variable Variabili Appearance panel Pannello Aspetto Applies when the linked file is relinked or updated. Si applica quando il file collegato viene ricollegato o aggiornato. Applies the next time VectorCraft starts. Si applica al prossimo avvio di VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Finché è impostata, la variabile d'ambiente VECTORCRAFT_TITLEBAR sceglie la barra del titolo. Apply Applica Apply Brush Applica pennello Apply Character Style Applica stile carattere diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7449d1f09..6c586d2eb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -920,6 +920,7 @@ font-technology Variable バリアブル Appearance of Black ブラックのアピアランス Appearance panel アピアランスパネル Applies the next time VectorCraft starts. VectorCraft の次回起動時に適用されます。 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 環境変数 VECTORCRAFT_TITLEBAR が設定されている間は、この変数でタイトルバーが決まります。 Applies when the linked file is relinked or updated. リンクファイルを再リンクまたは更新したときに適用されます。 Apply Brush ブラシを適用 Apply Character Style 文字スタイルを適用 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b22d3d09a..19e939a95 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3412,6 +3412,7 @@ font-technology Variable ცვლადი Panel menu პანელის მენიუ Unembed… ჩაშენების მოხსნა… Applies the next time VectorCraft starts. ძალაში შევა VectorCraft-ის მომდევნო გაშვებისას. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. სანამ გარემოს ცვლადი VECTORCRAFT_TITLEBAR დაყენებულია, სათაურის ზოლს ის ირჩევს. Enable Contextual Task Bar კონტექსტური ამოცანების ზოლის ჩართვა Next შემდეგი OK OK diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6d64b057b..78d691dfe 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -915,6 +915,7 @@ font-technology Variable 가변 Appearance of Black 검정 표시 방식 Appearance panel 모양새 패널 Applies the next time VectorCraft starts. 다음에 VectorCraft를 시작할 때 적용됩니다. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 환경 변수 VECTORCRAFT_TITLEBAR가 설정되어 있는 동안에는 이 변수가 제목 표시줄을 정합니다. Applies when the linked file is relinked or updated. 링크된 파일을 다시 연결하거나 업데이트할 때 적용됩니다. Apply Brush 브러시 적용 Apply Character Style 문자 스타일 적용 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b0e9ed9b8..52ad92ce3 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3647,6 +3647,7 @@ font-technology Variable Variabel Panel menu Paneelmenu Unembed… Insluiten ongedaan maken… Applies the next time VectorCraft starts. Wordt toegepast de volgende keer dat VectorCraft start. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Zolang de omgevingsvariabele VECTORCRAFT_TITLEBAR is ingesteld, kiest die de titelbalk. Enable Contextual Task Bar Contextuele taakbalk inschakelen Next Volgende OK OK diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8a52c4c51..b82d72bbd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3729,6 +3729,7 @@ font-technology Variable Zmienne Panel menu Menu panelu Unembed… Wyodrębnij… Applies the next time VectorCraft starts. Zostanie zastosowane przy następnym uruchomieniu VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dopóki zmienna środowiskowa VECTORCRAFT_TITLEBAR jest ustawiona, to ona wybiera pasek tytułu. Enable Contextual Task Bar Włącz kontekstowy pasek zadań Next Dalej OK OK diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3f3bdccb2..42b97e938 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -199,6 +199,7 @@ Appearance of Black Aparência do Preto Appearance panel Painel de Aparência Applies the next time VectorCraft starts. Aplica na próxima inicialização do VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Enquanto estiver definida, a variável de ambiente VECTORCRAFT_TITLEBAR escolhe a barra de título. Applies when the linked file is relinked or updated. Aplica quando o arquivo vinculado é revinculado ou atualizado. Apply Brush Aplicar Pincel Apply Character Style Aplicar Estilo de Caractere diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 59f8cd4f0..85ae7ece8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -202,6 +202,7 @@ font-technology Variable Вариативные Appearance panel Панель «Оформление» Applies when the linked file is relinked or updated. Применяется при повторной привязке или обновлении связанного файла. Applies the next time VectorCraft starts. Применяется при следующем запуске VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Пока переменная среды VECTORCRAFT_TITLEBAR задана, строку заголовка выбирает она. Apply Применить Apply Brush Применить кисть Apply Character Style Применить стиль символа diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c46f2970f..1e1700e43 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -192,6 +192,7 @@ font-technology Variable Variabla Appearance panel Utseendepanelen Applies when the linked file is relinked or updated. Gäller när den länkade filen länkas om eller uppdateras. Applies the next time VectorCraft starts. Gäller nästa gång VectorCraft startas. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljövariabeln VECTORCRAFT_TITLEBAR väljer namnlisten så länge den är angiven. Apply Använd Apply Brush Använd pensel Apply Character Style Använd teckenstil diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ce6b2229e..31d4a4e67 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -189,6 +189,7 @@ font-technology Variable Değişken Appearance panel Görünüm paneli Applies when the linked file is relinked or updated. Bağlantılı dosya yeniden bağlandığında veya güncellendiğinde geçerlidir. Applies the next time VectorCraft starts. VectorCraft'ın bir sonraki başlatılışında uygulanır. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. VECTORCRAFT_TITLEBAR ortam değişkeni ayarlı olduğu sürece başlık çubuğunu o seçer. Apply Uygula Apply Brush Fırça Uygula Apply Character Style Karakter Stilini Uygula diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 65384df71..2ab613e4c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -198,6 +198,7 @@ font-technology Variable Варіативні Appearance panel Панель «Оформлення» Applies when the linked file is relinked or updated. Використовується при повторній прив’язці або оновленні пов’язаного файлу. Applies the next time VectorCraft starts. Застосовується при наступному запуску VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Поки змінну середовища VECTORCRAFT_TITLEBAR задано, рядок заголовка вибирає вона. Apply Застосувати Apply Brush Застосувати пензель Apply Character Style Застосувати стиль символу diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d9ab99612..82ce7e7f3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -221,6 +221,7 @@ font-technology Variable Biến thiên Appearance Panel Bảng Diện mạo Appearance panel Bảng diện mạo Applies the next time VectorCraft starts. Áp dụng vào lần tiếp theo VectorCraft khởi động. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Khi biến môi trường VECTORCRAFT_TITLEBAR được đặt, biến này chọn thanh tiêu đề. Applies when the linked file is relinked or updated. Áp dụng khi tệp liên kết được liên kết lại hoặc cập nhật. Apply Áp dụng Apply Brush Áp dụng cọ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0daa3138f..66b79c6a4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4663,6 +4663,7 @@ imageTrace Outline 轮廓 Font Technology 字体技术 font-technology Variable 可变 Applies the next time VectorCraft starts. 下次启动 VectorCraft 时生效。 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 设置了环境变量 VECTORCRAFT_TITLEBAR 时,由它选择标题栏。 Collapse to Icons 折叠为图标 Decorative 装饰 Dim Images to: 图像变暗至: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 427c20da2..1a11d526d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -162,6 +162,7 @@ font-technology Variable 可變 Appearance panel 外觀面板 Applies when the linked file is relinked or updated. 重新連結或更新連結檔案時套用。 Applies the next time VectorCraft starts. 下次啟動 VectorCraft 時生效。 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 設定了環境變數 VECTORCRAFT_TITLEBAR 時,由它選擇標題列。 Apply 套用 Apply Brush 套用筆刷 Apply Character Style 套用字元樣式 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4efc27211..c3c55d6ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -521,6 +521,9 @@ pub struct VectorcraftApp { /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, + /// `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › User + /// Interface › System Title Bar does not apply while it is set. `false` unless the host sets it. + pub titlebar_from_env: bool, /// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`): /// sent again only when it changes, so idle frames don't spam the backend. pub(crate) last_window_title: String, @@ -813,6 +816,7 @@ impl VectorcraftApp { canvas_rect: None, hover_doc: None, custom_titlebar: false, + titlebar_from_env: false, last_window_title: String::new(), graphics_adapter: None, place: Default::default(), diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 9057aeb13..afe1af349 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -32,6 +32,8 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); + // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's note). + fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env)); app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields }); } @@ -278,9 +280,16 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { // Windows and Linux only: macOS always has the system's title bar, the web has none. PrefKind::Bool if sp.key == "systemTitleBar" => { if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { - bool_row(ui, d, sp.key, sp.label); - // The window's decorations are chosen once, when the app starts. - ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0)); + // The window's decorations are chosen once, when the app starts; while + // VECTORCRAFT_TITLEBAR is set it chooses them, and the checkbox is dimmed. + let from_env = d.bool("__titlebarFromEnv"); + ui.add_enabled_ui(!from_env, |ui| bool_row(ui, d, sp.key, sp.label)); + let note = if from_env { + tl!("The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set.") + } else { + tl!("Applies the next time VectorCraft starts.") + }; + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } } PrefKind::Bool => bool_row(ui, d, sp.key, sp.label), @@ -646,6 +655,61 @@ mod tests { assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true)); } + /// While VECTORCRAFT_TITLEBAR chooses the frame, System Title Bar says so in place of the + /// next-launch note, and its checkbox does not take clicks. + #[test] + fn system_title_bar_names_the_environment_variable_that_overrides_it() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let note = "The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set."; + let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + for from_env in [false, true] { + let mut a = app(); + a.titlebar_from_env = from_env; + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + assert_eq!(shown.iter().any(|t| t == note), supported && from_env, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported && !from_env, "{shown:?}"); + // A click on the checkbox's label: it turns the preference on only without the variable. + let label = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "System Title Bar" => Some(t.visual_bounding_rect()), + _ => None, + }); + if let Some(r) = label { + for pressed in [true, false] { + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(r.center()), + egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + } + assert_eq!(a.ui.dialog.as_ref().unwrap().bool("systemTitleBar"), !from_env, "clicked with the variable {from_env}"); + } + assert_eq!(label.is_some(), supported); + } + } + /// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and /// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop /// app reads before the window opens. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7bfbcfe16..3735659e9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -310,7 +310,11 @@ not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (th modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar -drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged. +drops its brand mark. On Linux and the BSDs the environment variable `VECTORCRAFT_TITLEBAR` chooses the frame for one +run, whatever the preference says: `app` keeps the app bar as the title bar, and `system` or `desktop` gives the +window the desktop's decorations. While it is set, the Preferences dialog dims System Title Bar and its +`__titlebarFromEnv` field is `true`. On macOS the window buttons are centred on the app bar; the web build is +unchanged. Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); diff --git a/docs/development.md b/docs/development.md index 74dc55a30..ac458332a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,7 +109,7 @@ On macOS the menus live in the system menu bar, not in the window's app bar (whi ## Linux: Wayland and X11 -On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. +On every Linux desktop the app bar is the window's title bar by default. Enable **Preferences › User Interface › System Title Bar** and restart to give the window the desktop's frame instead: KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers draw it, and on GNOME/Wayland winit draws its font-free Adwaita window controls (`wayland-csd-adwaita-notitle`). `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run, whatever the preference says. The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4f5dbcba1..c2fa2912c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -610,6 +610,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+Shift+X (`type.resetHorizontalScale`) sets the horizontal scale of the selected type, or of the text the Type tool has selected, to 100% as the Character panel shows it (the object's own stretch included), in one undo step; at a caret it leaves the text as it is. - Cmd/Ctrl+Alt+N (`file.newWithoutDialog`) makes a document without opening the New Document dialog, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new document with no New Document dialog box. The page does not say which settings the document gets. VectorCraft gives it the settings the dialog opens on (the first Recent entry, or the Print category's Letter while Recent is empty) and the next untitled name. - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). + - On Linux the app bar is the title bar on every desktop, KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers included, unless Preferences › User Interface › System Title Bar gives the window the desktop's frame; `VECTORCRAFT_TITLEBAR=app` or `=system` chooses the frame for one run. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain. From 5018ff319f108976a35673834fc9a30e01448175 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 11:59:56 -0700 Subject: [PATCH 07/36] i18n: Turkish rows for the JPEG XL messages Turkish now translates every message, so it gets rows for the three JPEG XL messages. --- crates/ui-egui/src/i18n/tr.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ce6b2229e..3e4975e75 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4299,6 +4299,9 @@ axis Both Her İkisi @msg the PSD file is cut short PSD dosyası kısa kesildi @msg the PSD file's header is damaged PSD dosyasının başlığı hasarlı @msg the PSD file's image data can't be read: {e} PSD dosyasının görüntü verileri okunamıyor: {e} +@msg the JPEG XL file is cut short JPEG XL dosyası kısa kesildi +@msg CMYK JPEG XL images can't be read CMYK JPEG XL görüntüleri okunamıyor +@msg the JPEG XL file can't be read: {e} JPEG XL dosyası okunamıyor: {e} Video video De-Interlace… Taramasızlaştırma… De-Interlace Taramasızlaştırma From eb65e64882712989e72dea9b5348935696350a3e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 01:07:48 -0700 Subject: [PATCH 08/36] Docs: name VECTORCRAFT_TITLEBAR=desktop and qualify the Linux app bar line --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 09f540fa4..93492a652 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -610,8 +610,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+Shift+X (`type.resetHorizontalScale`) sets the horizontal scale of the selected type, or of the text the Type tool has selected, to 100% as the Character panel shows it (the object's own stretch included), in one undo step; at a caret it leaves the text as it is. - Cmd/Ctrl+Alt+N (`file.newWithoutDialog`) makes a document without opening the New Document dialog, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new document with no New Document dialog box. The page does not say which settings the document gets. VectorCraft gives it the settings the dialog opens on (the first Recent entry, or the Print category's Letter while Recent is empty) and the next untitled name. - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). - - On Linux the app bar is the title bar on every desktop, KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers included, unless Preferences › User Interface › System Title Bar gives the window the desktop's frame; `VECTORCRAFT_TITLEBAR=app` or `=system` chooses the frame for one run. - - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. + - On Linux the app bar is the title bar on every desktop, KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers included, unless Preferences › User Interface › System Title Bar gives the window the desktop's frame; `VECTORCRAFT_TITLEBAR=app` chooses VectorCraft's bar and `=system` or `=desktop` (the two are the same) chooses the desktop's frame for one run. + - On Windows, and on Linux unless the desktop's frame is chosen, the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). - On Windows a maximized window stops one pixel short of each monitor edge where an auto-hidden taskbar sits. - **Tools:** From 8a0b7969455972f0af377e9de2c16a1afd99df17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:05:23 -0700 Subject: [PATCH 09/36] Recolor: report the locked and hidden art a recolor of everything left out When the selection is exactly what select.all takes, recolor.apply and edit.colors.invert, toCMYK, toGrayscale, toRGB, saturate and adjustBalance now count the objects select.all left out whose colors they would have changed: locked or hidden objects, the objects of locked or hidden layers and sublayers, and objects on locked artboards (counted as locked). In isolation mode the count covers the isolated group's members. An object both hidden and locked counts as hidden. Template layers' art is not counted. Locked and hidden members of a selected group are recolored as before and are not counted. With any other selection, or with ids, both counts are 0. Lock and visibility work as before. The reply carries notChanged: {locked, hidden}, and a warning when a count is above 0, which the app shows in the status bar, for example "Recolor completed; 3 locked objects and 1 hidden object were left unchanged". A trial pass over the left-out objects finds which ones would change. In recolor.apply, an object that the group rewrite then changes is not counted. The message has rows in the 20 catalogs that translate messages. docs/mcp.md describes the counts. --- crates/engine/src/cmd/colorcmds.rs | 126 +++++++++++++++++++++++--- crates/engine/src/cmd/recolor.rs | 23 ++++- crates/engine/src/tests/editcolors.rs | 44 +++++++++ crates/engine/src/tests/recolor.rs | 72 +++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 7 ++ crates/ui-egui/src/i18n/de.tsv | 7 ++ crates/ui-egui/src/i18n/el.tsv | 7 ++ crates/ui-egui/src/i18n/es.tsv | 7 ++ crates/ui-egui/src/i18n/fi.tsv | 7 ++ crates/ui-egui/src/i18n/fr.tsv | 7 ++ crates/ui-egui/src/i18n/hu.tsv | 7 ++ crates/ui-egui/src/i18n/it.tsv | 7 ++ crates/ui-egui/src/i18n/ja.tsv | 7 ++ crates/ui-egui/src/i18n/ka.tsv | 7 ++ crates/ui-egui/src/i18n/ko.tsv | 7 ++ crates/ui-egui/src/i18n/pl.tsv | 7 ++ crates/ui-egui/src/i18n/pt-br.tsv | 7 ++ crates/ui-egui/src/i18n/ru.tsv | 7 ++ crates/ui-egui/src/i18n/sv.tsv | 7 ++ crates/ui-egui/src/i18n/tests.rs | 21 +++++ crates/ui-egui/src/i18n/tr.tsv | 7 ++ crates/ui-egui/src/i18n/uk.tsv | 7 ++ crates/ui-egui/src/i18n/vi.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 ++ docs/mcp.md | 9 ++ 26 files changed, 419 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 49b729c8a..f068b41b5 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "Invert Colors", &invert) ), @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Convert to CMYK", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| recolor(s, p, "Convert to CMYK", &to_cmyk) ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Convert to Grayscale", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| recolor(s, p, "Convert to Grayscale", &to_gray) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Convert to RGB", ["Edit", "Edit Colors"], None, - "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}", + "{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}", has_selection, |s, p| { recolor(s, p, "Convert to RGB", &|c| { @@ -71,7 +71,7 @@ pub fn specs() -> Vec { "Saturate…", ["Edit", "Edit Colors"], None, - "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", + "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)", has_selection, saturate ), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { "Adjust Color Balance…", ["Edit", "Edit Colors"], None, - "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed}", + "{mode?: \"rgb\"|\"cmyk\"|\"gray\"|\"global\" (default: from the channels given), r?, g?, b? | c?, m?, y?, k? | gray? | tint?: -100..100 (% added per channel, default 0; `tint` is global mode: it shifts the tints of colours and gradient stops linked to global and spot swatches, which stay linked, and leaves other colours and images alone), convert?: false (true: the results stay in the adjusted model; false: each colour keeps its own), fill?, stroke?, includeImages?, includePatterns?, ids?} reaching what edit.colors.invert does → {changed, notChanged, warning?} (as edit.colors.invert)", has_selection, adjust_balance ), @@ -213,6 +213,17 @@ impl<'a> Recolor<'a> { Self { links: Some(links), ..Self::new(scope, f) } } + /// A pass that maps colors as this one does, with nothing recolored yet. + fn again(&self) -> Self { + Self { links: self.links, ..Self::new(self.scope, self.f) } + } + + /// The entries of `objects` (ids, each with a flag) whose object this pass would change. The + /// document stays as it is. + pub(crate) fn would_change(mut self, d: &Document, objects: &[(NodeId, T)]) -> Vec<(NodeId, T)> { + objects.iter().copied().filter(|(id, _)| d.node(*id).is_some_and(|n| self.subtree(d, n).is_some())).collect() + } + /// Recolour the objects `ids` and everything inside them (each once), add the new images and /// pattern swatches, and return the number of paints, meshes and images changed. pub(crate) fn run(mut self, d: &mut Document, ids: &[NodeId]) -> usize { @@ -335,14 +346,105 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) run_pass(s, p, label, Recolor::new(Scope::of(p), f)) } -/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. +/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. The reply's +/// `notChanged` counts the locked and hidden objects the selection left out ([`left_out`]) that +/// the pass would have changed. fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result { - let ids = match ids_param(p, "ids") { - Some(v) => v, - None => selected_roots(s)?, + let (ids, left) = match ids_param(p, "ids") { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } }; - let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?; - Ok(json!({ "changed": n })) + let (n, not_changed) = s.edit(label, |d, _| { + let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); + Ok((pass.run(d, &ids), not_changed)) + })?; + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with +/// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All +/// takes (in isolation mode, those of the isolated group), these are the objects beside them that +/// Select All skips: hidden or locked objects, the objects of hidden or locked layers and +/// sublayers, and, outside isolation mode, objects on locked artboards (counted as locked). The art +/// of template layers and the objects inside a group are not listed. With any other `roots`, none. +pub(crate) fn left_out(d: &Document, iso: Option, roots: &[NodeId]) -> Vec<(NodeId, bool)> { + fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_template() { + continue; + } + let (hidden, locked) = (hidden || !c.visible, locked || c.locked); + if c.is_layer() { + visit(c, hidden, locked, all, board_locked, out); + } else if !all.contains(&c.id) && (hidden || locked || board_locked(c)) { + out.push((c.id, hidden)); + } + } + } + let all: HashSet = super::select::selectable(d, iso).into_iter().collect(); + let picked: HashSet = roots.iter().copied().collect(); + if picked.is_empty() || picked != all { + return vec![]; + } + let mut out = vec![]; + match iso.and_then(|i| d.node(i)) { + Some(c) => visit(c, false, false, &all, &|_| false, &mut out), + None => { + let boards = d.locked_artboards(); + let board_locked = |n: &Node| !boards.is_empty() && boards.holds(n); + for l in d.layers.iter().filter(|l| !l.is_template()) { + visit(l, !l.visible, l.locked, &all, &board_locked, &mut out); + } + } + } + out +} + +/// How many locked and hidden objects a recolor left out ([`left_out`]) although it would have +/// changed their colors. An object both hidden and locked counts as hidden. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct NotChanged { + pub locked: usize, + pub hidden: usize, +} + +impl NotChanged { + /// The counts of objects given by whether each is hidden. + pub(crate) fn of(hidden: impl IntoIterator) -> Self { + hidden.into_iter().fold(Self::default(), |n, h| if h { Self { hidden: n.hidden + 1, ..n } } else { Self { locked: n.locked + 1, ..n } }) + } + + /// Add `notChanged` to reply `out`, and a `warning`, which the app shows in its status bar, + /// when any object is counted. + pub(crate) fn report(self, out: &mut Value) { + out["notChanged"] = json!({ "locked": self.locked, "hidden": self.hidden }); + if let Some(w) = self.warning() { + out["warning"] = json!(w); + } + } + + fn warning(self) -> Option { + let part = |n: usize, kind: &str| match n { + 0 => None, + 1 => Some(format!("1 {kind} object")), + n => Some(format!("{n} {kind} objects")), + }; + match (part(self.locked, "locked"), part(self.hidden, "hidden")) { + (Some(locked), Some(hidden)) => Some(format!("Recolor completed; {locked} and {hidden} were left unchanged")), + (Some(objects), None) | (None, Some(objects)) if self.locked + self.hidden == 1 => { + Some(format!("Recolor completed; {objects} was left unchanged")) + } + (Some(objects), None) | (None, Some(objects)) => Some(format!("Recolor completed; {objects} were left unchanged")), + (None, None) => None, + } + } } fn saturate(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 8a4fbb09d..4a29afd4b 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -12,7 +12,7 @@ use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::{node_colors, node_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; -use super::colorcmds::{Recolor, Scope}; +use super::colorcmds::{NotChanged, Recolor, Scope, left_out}; use super::{CommandSpec, Result, Session, always, bad, bool_or, checked_ids_param, cmd, color_value, has_doc, index, str_param}; pub fn specs() -> Vec { @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), @@ -419,6 +419,10 @@ fn apply(s: &mut Session, p: &Value) -> Result { let row_swatches: Vec> = rows.iter().map(|r| r.new_color().and_then(|c| lib_swatches.iter().find(|w| w.paint.color() == Some(c)).cloned())).collect(); let ids = s.doc()?.selection.objects.clone(); + let left = { + let st = s.doc()?; + left_out(&st.doc, st.isolation, &super::edit::roots_of(&st.doc, ids.clone())) + }; let group = str_param(p, "group"); if ids.is_empty() && group.is_none() { return Err(bad(C, "select artwork to recolour, or give a colour `group`")); @@ -460,7 +464,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { None => rows.iter().filter_map(Row::new_color).collect(), }; let rename = str_param(p, "rename"); - let (changed, edit, added, converted) = s.edit(label, |d, _| { + let (changed, edit, added, converted, not_changed) = s.edit(label, |d, _| { // The rows' library swatches join the document (one it has with that name and colour is // used as it is). let mut added = vec![]; @@ -497,11 +501,22 @@ fn apply(s: &mut Session, p: &Value) -> Result { let map = ColorMap::new(&rows, method, own_model, &links, kept); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); + // The objects left out whose colors the pass would change, as they are now. The trial + // pass does not count towards the note on color models. + let before: Vec<(Node, bool)> = Recolor::with_links(scope, &f, &lf) + .would_change(d, &left) + .into_iter() + .filter_map(|(id, hidden)| Some((d.node(id)?.clone(), hidden))) + .collect(); + map.converted.set(false); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; - Ok((changed, edit, added, map.converted.get())) + // Rewriting the group's swatches can change art linked to them, left out or not. + let not_changed = NotChanged::of(before.iter().filter(|(n, _)| d.node(n.id) == Some(n)).map(|(_, hidden)| *hidden)); + Ok((changed, edit, added, map.converted.get(), not_changed)) })?; let mut out = json!({ "changed": changed, "swatches": added }); + not_changed.report(&mut out); if let Some(e) = edit { e.defaults(s); out["relinked"] = json!(e.relinked()); diff --git a/crates/engine/src/tests/editcolors.rs b/crates/engine/src/tests/editcolors.rs index 511431c3c..39152576c 100644 --- a/crates/engine/src/tests/editcolors.rs +++ b/crates/engine/src/tests/editcolors.rs @@ -355,3 +355,47 @@ fn recolor_writes_a_new_pattern_swatch() { s.execute("edit.colors.invert", &json!({})).unwrap(); assert_eq!((fill_pattern(&s, r), tile_fill(&s, "Dots 2")), ("Dots 2".to_string(), "#00ffff".to_string())); } + +#[test] +fn edit_colors_of_everything_count_the_hidden_objects_left_out() { + let mut s = session(); + let shown = rect(&mut s, json!("#ff0000")); + let hidden = rect(&mut s, json!("#ff0000")); + // A hidden object with no paint would not change. + let unpainted = rect(&mut s, json!("#ff0000")); + s.execute("paint.setFill", &json!({"ids": [unpainted.0], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [unpainted.0], "none": true})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [hidden.0, unpainted.0], "visible": false})).unwrap(); + // A locked member of a selected group is recolored with it. + let member = rect(&mut s, json!("#ff0000")); + let locked_member = rect(&mut s, json!("#ff0000")); + s.execute("select.set", &json!({"ids": [member.0, locked_member.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [locked_member.0], "locked": true})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute("edit.colors.invert", &json!({})).unwrap(); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 1}), "{r}"); + assert_eq!(r["warning"], "Recolor completed; 1 hidden object was left unchanged"); + assert_eq!((fill_of(&s, shown).to_hex(), fill_of(&s, hidden).to_hex()), ("#00ffff".to_string(), "#ff0000".to_string())); + assert_eq!(fill_of(&s, locked_member).to_hex(), "#00ffff"); + // Objects given by `ids` are the whole scope. + let r = s.execute("edit.colors.toGrayscale", &json!({"ids": [shown.0]})).unwrap(); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + +#[test] +fn edit_colors_in_isolation_mode_count_the_locked_members_left_out() { + let mut s = session(); + let a = rect(&mut s, json!("#ff0000")); + let b = rect(&mut s, json!("#ff0000")); + let c = rect(&mut s, json!("#ff0000")); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("layer.setProps", &json!({"ids": [b.0, c.0], "locked": true})).unwrap(); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute("edit.colors.saturate", &json!({"intensity": -50})).unwrap(); + assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 0}), "{r}"); + assert_eq!(r["warning"], "Recolor completed; 2 locked objects were left unchanged"); +} diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index fb9959ded..b9b4377e1 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -572,6 +572,78 @@ fn model_param_must_be_a_string_when_given() { assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed"); } +/// Sets the Layers panel's lock and visibility of `ids` (objects or layers). +fn set_props(s: &mut Session, ids: &[NodeId], props: Value) { + let mut p = props; + p["ids"] = json!(ids.iter().map(|i| i.0).collect::>()); + run(s, "layer.setProps", p); +} + +#[test] +fn recolor_of_everything_counts_the_locked_and_hidden_objects_it_left_out() { + let mut s = session(); + let open = rect(&mut s, json!("#ff0000")); + let locked = rect(&mut s, json!("#ff0000")); + let hidden = rect(&mut s, json!("#ff0000")); + let both = rect(&mut s, json!("#ff0000")); + // A locked object the map leaves as it is was not left out of anything. + let other = rect(&mut s, json!("#00ff00")); + set_props(&mut s, &[locked, other], json!({"locked": true})); + set_props(&mut s, &[hidden], json!({"visible": false})); + set_props(&mut s, &[both], json!({"visible": false, "locked": true})); + // The objects of a locked layer count as locked; a template layer's art is not counted. + let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); + let on_locked_layer = rect(&mut s, json!("#ff0000")); + set_props(&mut s, &[layer], json!({"locked": true})); + let template = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); + rect(&mut s, json!("#ff0000")); + set_props(&mut s, &[template], json!({"template": true})); + select_all(&mut s); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}})); + assert_eq!(r["notChanged"], json!({"locked": 2, "hidden": 2}), "{r}"); + assert_eq!(r["warning"], "Recolor completed; 2 locked objects and 2 hidden objects were left unchanged"); + assert_eq!(fill(&s, open).to_hex(), "#0000ff"); + for id in [locked, hidden, both, on_locked_layer] { + assert_eq!(fill(&s, id).to_hex(), "#ff0000", "locked and hidden art keeps its colors"); + } +} + +#[test] +fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() { + let mut s = session(); + let ids = [rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000")), rect(&mut s, json!("#ff0000"))]; + select_all(&mut s); + run(&mut s, "object.group", json!({})); + set_props(&mut s, &ids[1..2], json!({"locked": true})); + set_props(&mut s, &ids[2..3], json!({"visible": false})); + let node = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(node(&s, ids[1]).locked && !node(&s, ids[2]).visible); + select_all(&mut s); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert!(ids.iter().all(|id| fill(&s, *id).to_hex() == "#0000ff")); +} + +#[test] +fn recolor_of_part_of_the_art_counts_nothing_left_out() { + let mut s = session(); + let picked = rect(&mut s, json!("#ff0000")); + let locked = rect(&mut s, json!("#ff0000")); + rect(&mut s, json!("#ff0000")); + set_props(&mut s, &[locked], json!({"locked": true})); + run(&mut s, "select.set", json!({"ids": [picked.0]})); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#0000ff"}})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + // Everything selected, with nothing locked or hidden. + run(&mut s, "object.unlockAll", json!({})); + select_all(&mut s); + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + #[test] fn document_model_converts_the_colours_of_a_group_too() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 668f75051..d0b0e390a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4565,6 +4565,13 @@ imageTrace Outline Obrys @msg select an image vyberte obraz @msg select an object in perspective vyberte objekt v perspektivě @msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení +@msg Recolor completed; {locked} and {hidden} were left unchanged Přebarvení dokončeno; beze změny: {locked} a {hidden} +@msg Recolor completed; {objects} were left unchanged Přebarvení dokončeno; beze změny: {objects} +@msg Recolor completed; {objects} was left unchanged Přebarvení dokončeno; beze změny: {objects} +@msg 1 locked object zamčený objekt (1) +@msg {n} locked objects zamčené objekty ({n}) +@msg 1 hidden object skrytý objekt (1) +@msg {n} hidden objects skryté objekty ({n}) @msg select at least two objects vyberte alespoň dva objekty @msg exit isolation mode first nejprve ukončete izolovaný režim @msg select objects, not layers vyberte objekty, ne vrstvy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4b2f24c45..9331b28bc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4184,6 +4184,13 @@ theme Appearance Erscheinungsbild @msg select an image wählen Sie ein Bild aus @msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus @msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus +@msg Recolor completed; {locked} and {hidden} were left unchanged Neufärben abgeschlossen; unverändert: {locked} und {hidden} +@msg Recolor completed; {objects} were left unchanged Neufärben abgeschlossen; unverändert: {objects} +@msg Recolor completed; {objects} was left unchanged Neufärben abgeschlossen; unverändert: {objects} +@msg 1 locked object 1 gesperrtes Objekt +@msg {n} locked objects {n} gesperrte Objekte +@msg 1 hidden object 1 ausgeblendetes Objekt +@msg {n} hidden objects {n} ausgeblendete Objekte @msg select at least two objects wählen Sie mindestens zwei Objekte aus @msg exit isolation mode first beenden Sie zuerst den Isolationsmodus @msg select objects, not layers wählen Sie Objekte aus, keine Ebenen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0dcaa0931..e6d55a0c0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4221,6 +4221,13 @@ theme Appearance Εμφάνιση @msg select an image επιλέξτε εικόνα @msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική @msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό +@msg Recolor completed; {locked} and {hidden} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {locked} και {hidden} +@msg Recolor completed; {objects} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {objects} +@msg Recolor completed; {objects} was left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {objects} +@msg 1 locked object 1 κλειδωμένο αντικείμενο +@msg {n} locked objects {n} κλειδωμένα αντικείμενα +@msg 1 hidden object 1 κρυφό αντικείμενο +@msg {n} hidden objects {n} κρυφά αντικείμενα @msg select at least two objects επιλέξτε τουλάχιστον δύο αντικείμενα @msg exit isolation mode first βγείτε πρώτα από τη λειτουργία απομόνωσης @msg select objects, not layers επιλέξτε αντικείμενα, όχι επίπεδα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cfe0019e7..3b21c68d2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4263,6 +4263,13 @@ axis Both Ambos @msg select an image selecciona una imagen @msg select an object in perspective selecciona un objeto en perspectiva @msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloreado completado; sin cambios: {locked} y {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloreado completado; sin cambios: {objects} +@msg Recolor completed; {objects} was left unchanged Recoloreado completado; sin cambios: {objects} +@msg 1 locked object 1 objeto bloqueado +@msg {n} locked objects {n} objetos bloqueados +@msg 1 hidden object 1 objeto oculto +@msg {n} hidden objects {n} objetos ocultos @msg select at least two objects selecciona al menos dos objetos @msg exit isolation mode first sal primero del modo de aislamiento @msg select objects, not layers selecciona objetos, no capas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 353e6ace4..9fc70a1a3 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4472,6 +4472,13 @@ imageTrace Outline Ääriviiva @msg select an image valitse kuva @msg select an object in perspective valitse perspektiivissä oleva objekti @msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä) +@msg Recolor completed; {locked} and {hidden} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {locked} ja {hidden} +@msg Recolor completed; {objects} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {objects} +@msg Recolor completed; {objects} was left unchanged Uudelleenväritys valmis; muuttamatta jäi: {objects} +@msg 1 locked object 1 lukittu objekti +@msg {n} locked objects {n} lukittua objektia +@msg 1 hidden object 1 piilotettu objekti +@msg {n} hidden objects {n} piilotettua objektia @msg select at least two objects valitse vähintään kaksi objektia @msg exit isolation mode first poistu ensin eristystilasta @msg select objects, not layers valitse objekteja, ei tasoja diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ef43c205e..7de87ccce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4260,6 +4260,13 @@ axis Both Les deux @msg select an image sélectionnez une image @msg select an object in perspective sélectionnez un objet en perspective @msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloration terminée ; sans changement : {objects} +@msg Recolor completed; {objects} was left unchanged Recoloration terminée ; sans changement : {objects} +@msg 1 locked object 1 objet verrouillé +@msg {n} locked objects {n} objets verrouillés +@msg 1 hidden object 1 objet masqué +@msg {n} hidden objects {n} objets masqués @msg select at least two objects sélectionnez au moins deux objets @msg exit isolation mode first quittez d’abord le mode Isolation @msg select objects, not layers sélectionnez des objets, pas des calques diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9fd8704e2..7a56b0bd0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4973,6 +4973,13 @@ imageTrace Outline Körvonal @msg select an Image Trace object jelölj ki egy képvektorizálási objektumot @msg select an object in perspective jelölj ki egy perspektivikus objektumot @msg select artwork (or a colour group) to recolor jelölj ki átszínezendő grafikát (vagy színcsoportot) +@msg Recolor completed; {locked} and {hidden} were left unchanged Átszínezés kész; változatlan maradt: {locked} és {hidden} +@msg Recolor completed; {objects} were left unchanged Átszínezés kész; változatlan maradt: {objects} +@msg Recolor completed; {objects} was left unchanged Átszínezés kész; változatlan maradt: {objects} +@msg 1 locked object 1 zárolt objektum +@msg {n} locked objects {n} zárolt objektum +@msg 1 hidden object 1 rejtett objektum +@msg {n} hidden objects {n} rejtett objektum @msg select at least two objects jelölj ki legalább két objektumot @msg exit isolation mode first előbb lépj ki az elkülönítési módból @msg select objects, not layers objektumokat jelölj ki, rétegeket ne diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3e2c30079..39bbc0e9a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4303,6 +4303,13 @@ axis Both Entrambi @msg select an image seleziona un'immagine @msg select an object in perspective seleziona un oggetto in prospettiva @msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare +@msg Recolor completed; {locked} and {hidden} were left unchanged Ricolorazione completata; senza modifiche: {locked} e {hidden} +@msg Recolor completed; {objects} were left unchanged Ricolorazione completata; senza modifiche: {objects} +@msg Recolor completed; {objects} was left unchanged Ricolorazione completata; senza modifiche: {objects} +@msg 1 locked object 1 oggetto bloccato +@msg {n} locked objects {n} oggetti bloccati +@msg 1 hidden object 1 oggetto nascosto +@msg {n} hidden objects {n} oggetti nascosti @msg select at least two objects seleziona almeno due oggetti @msg exit isolation mode first esci prima dalla modalità isolamento @msg select objects, not layers seleziona oggetti, non livelli diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4166e0eb5..3b2aebb0f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4417,6 +4417,13 @@ swatch Bright Violet ビビッドバイオレット @msg print settings are not an object プリント設定がオブジェクト形式ではありません @msg no colour group `{g}` カラーグループ `{g}` が見つかりません @msg select artwork (or a colour group) to recolor 再配色するアートワーク (またはカラーグループ) を選択してください +@msg Recolor completed; {locked} and {hidden} were left unchanged 再配色が完了しました。変更されなかったもの: {locked}、{hidden} +@msg Recolor completed; {objects} were left unchanged 再配色が完了しました。変更されなかったもの: {objects} +@msg Recolor completed; {objects} was left unchanged 再配色が完了しました。変更されなかったもの: {objects} +@msg 1 locked object ロックされたオブジェクト 1 個 +@msg {n} locked objects ロックされたオブジェクト {n} 個 +@msg 1 hidden object 非表示のオブジェクト 1 個 +@msg {n} hidden objects 非表示のオブジェクト {n} 個 @msg Downloaded {_1} file(s) {_1} 個のファイルをダウンロードしました @msg Preview in Browser needs the desktop app (the web downloads files instead) 「ブラウザーでプレビュー」にはデスクトップアプリが必要です (Web 版では代わりにファイルをダウンロードします) @msg no file system here この環境にはファイルシステムがありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index dd782c070..04adf9cd5 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4026,6 +4026,13 @@ font-technology Variable ცვლადი @msg select an image მონიშნეთ გამოსახულება @msg select an object in perspective მონიშნეთ ობიექტი პერსპექტივაში @msg select artwork (or a colour group) to recolor მონიშნეთ ხელახლა შესაფერადებელი ნახატი (ან ფერთა ჯგუფი) +@msg Recolor completed; {locked} and {hidden} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {locked} და {hidden} +@msg Recolor completed; {objects} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {objects} +@msg Recolor completed; {objects} was left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {objects} +@msg 1 locked object 1 ჩაკეტილი ობიექტი +@msg {n} locked objects {n} ჩაკეტილი ობიექტი +@msg 1 hidden object 1 დამალული ობიექტი +@msg {n} hidden objects {n} დამალული ობიექტი @msg select at least two objects მონიშნეთ ორი ობიექტი მაინც @msg exit isolation mode first ჯერ გამოდით იზოლაციის რეჟიმიდან @msg select objects, not layers მონიშნეთ ობიექტები და არა შრეები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4659fc3c3..5cbcfba02 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4349,6 +4349,13 @@ theme Appearance 외관 @msg print settings are not an object 인쇄 설정이 개체가 아닙니다 @msg no colour group `{g}` 색상 그룹 `{g}`이(가) 없습니다 @msg select artwork (or a colour group) to recolor 리컬러할 아트웍(또는 색상 그룹)을 선택하세요 +@msg Recolor completed; {locked} and {hidden} were left unchanged 색상 재지정이 완료되었습니다. 변경되지 않음: {locked}, {hidden} +@msg Recolor completed; {objects} were left unchanged 색상 재지정이 완료되었습니다. 변경되지 않음: {objects} +@msg Recolor completed; {objects} was left unchanged 색상 재지정이 완료되었습니다. 변경되지 않음: {objects} +@msg 1 locked object 잠긴 개체 1개 +@msg {n} locked objects 잠긴 개체 {n}개 +@msg 1 hidden object 숨겨진 개체 1개 +@msg {n} hidden objects 숨겨진 개체 {n}개 @msg Downloaded {_1} file(s) 파일 {_1}개를 다운로드했습니다 @msg Preview in Browser needs the desktop app (the web downloads files instead) 브라우저에서 미리 보기는 데스크톱 앱이 필요합니다(웹에서는 파일을 다운로드합니다) @msg no file system here 여기에는 파일 시스템이 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1a458e992..f048ca3e7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4392,6 +4392,13 @@ imageTrace Outline Kontur @msg select an image zaznacz obraz @msg select an object in perspective zaznacz obiekt w perspektywie @msg select artwork (or a colour group) to recolor zaznacz kompozycję (lub grupę kolorów), aby zmienić kolory +@msg Recolor completed; {locked} and {hidden} were left unchanged Zmiana kolorów zakończona; bez zmian: {locked} i {hidden} +@msg Recolor completed; {objects} were left unchanged Zmiana kolorów zakończona; bez zmian: {objects} +@msg Recolor completed; {objects} was left unchanged Zmiana kolorów zakończona; bez zmian: {objects} +@msg 1 locked object zablokowany obiekt (1) +@msg {n} locked objects zablokowane obiekty ({n}) +@msg 1 hidden object ukryty obiekt (1) +@msg {n} hidden objects ukryte obiekty ({n}) @msg select at least two objects zaznacz co najmniej dwa obiekty @msg exit isolation mode first najpierw wyjdź z trybu izolacji @msg select objects, not layers zaznacz obiekty, nie warstwy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a3561648f..5598a10ac 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3835,6 +3835,13 @@ axis Both Ambos @msg select an image selecione uma imagem @msg select an object in perspective selecione um objeto em perspectiva @msg select artwork (or a colour group) to recolor selecione uma arte-final (ou um grupo de cores) para recolorir +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloração concluída; sem alteração: {locked} e {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloração concluída; sem alteração: {objects} +@msg Recolor completed; {objects} was left unchanged Recoloração concluída; sem alteração: {objects} +@msg 1 locked object 1 objeto bloqueado +@msg {n} locked objects {n} objetos bloqueados +@msg 1 hidden object 1 objeto oculto +@msg {n} hidden objects {n} objetos ocultos @msg select at least two objects selecione pelo menos dois objetos @msg exit isolation mode first saia do modo de isolamento primeiro @msg select objects, not layers selecione objetos, não camadas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5ad5ae6fe..d012e5962 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4299,6 +4299,13 @@ axis Both Оба @msg select an image выделите изображение @msg select an object in perspective выделите объект в перспективе @msg select artwork (or a colour group) to recolor выделите рисунок (или цветовую группу) для перекрашивания +@msg Recolor completed; {locked} and {hidden} were left unchanged Перекрашивание завершено; без изменений: {locked} и {hidden} +@msg Recolor completed; {objects} were left unchanged Перекрашивание завершено; без изменений: {objects} +@msg Recolor completed; {objects} was left unchanged Перекрашивание завершено; без изменений: {objects} +@msg 1 locked object заблокированный объект (1) +@msg {n} locked objects заблокированные объекты ({n}) +@msg 1 hidden object скрытый объект (1) +@msg {n} hidden objects скрытые объекты ({n}) @msg select at least two objects выделите как минимум два объекта @msg exit isolation mode first сначала выйдите из режима изоляции @msg select objects, not layers выделите объекты, а не слои diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 355b52783..c05e05557 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4172,6 +4172,13 @@ axis Both Båda @msg select an image markera en bild @msg select an object in perspective markera ett objekt i perspektiv @msg select artwork (or a colour group) to recolor markera grafik (eller en färggrupp) att färga om +@msg Recolor completed; {locked} and {hidden} were left unchanged Omfärgning klar; utan ändring: {locked} och {hidden} +@msg Recolor completed; {objects} were left unchanged Omfärgning klar; utan ändring: {objects} +@msg Recolor completed; {objects} was left unchanged Omfärgning klar; utan ändring: {objects} +@msg 1 locked object 1 låst objekt +@msg {n} locked objects {n} låsta objekt +@msg 1 hidden object 1 dolt objekt +@msg {n} hidden objects {n} dolda objekt @msg select at least two objects markera minst två objekt @msg exit isolation mode first avsluta isoleringsläget först @msg select objects, not layers markera objekt, inte lager diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a92562e7c..fb3e168b5 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1431,6 +1431,27 @@ fn messages_translate_by_template_and_their_parts() { assert_eq!(message(Lang::EN, nested), nested); } +/// The note `recolor.apply` and the Edit Colors commands give when they leave out locked or hidden +/// objects is translated whole, its counts included, in every complete-message language. +#[test] +fn recolor_notes_on_objects_left_unchanged_translate_with_their_counts() { + let notes = [ + ("Recolor completed; 2 locked objects and 1 hidden object were left unchanged", ["2", "1"]), + ("Recolor completed; 1 locked object and 3 hidden objects were left unchanged", ["1", "3"]), + ("Recolor completed; 1 locked object was left unchanged", ["1", "1"]), + ("Recolor completed; 5 hidden objects were left unchanged", ["5", "5"]), + ]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (note, counts) in notes { + let shown = message(lang, note); + let english = ["completed", "locked", "hidden", "object", "unchanged"]; + assert!(english.iter().all(|w| !shown.contains(w)), "{code}: {shown}"); + assert!(counts.iter().all(|n| shown.contains(n)), "{code}: {shown}"); + } + } +} + #[test] fn package_status_message_templates_parse() { let c = Catalog::parse(concat!( diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 50c3e5f4c..7bc0eaf6d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4028,6 +4028,13 @@ axis Both Her İkisi @msg select an image bir resim seç @msg select an object in perspective perspektifte bir nesne seç @msg select artwork (or a colour group) to recolor yeniden renklendirilecek resmi (veya bir renk grubunu) seçin +@msg Recolor completed; {locked} and {hidden} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {locked} ve {hidden} +@msg Recolor completed; {objects} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {objects} +@msg Recolor completed; {objects} was left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {objects} +@msg 1 locked object 1 kilitli nesne +@msg {n} locked objects {n} kilitli nesne +@msg 1 hidden object 1 gizli nesne +@msg {n} hidden objects {n} gizli nesne @msg select at least two objects en az iki nesne seç @msg exit isolation mode first önce izolasyon modundan çık @msg select objects, not layers katmanları değil nesneleri seçin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ea16da598..aaeab9a06 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4287,6 +4287,13 @@ axis Both Обидві @msg select an image виділіть зображення @msg select an object in perspective виділіть об’єкт у перспективі @msg select artwork (or a colour group) to recolor виділіть малюнок (або колірну групу) для перефарбовування +@msg Recolor completed; {locked} and {hidden} were left unchanged Перефарбовування завершено; без змін: {locked} і {hidden} +@msg Recolor completed; {objects} were left unchanged Перефарбовування завершено; без змін: {objects} +@msg Recolor completed; {objects} was left unchanged Перефарбовування завершено; без змін: {objects} +@msg 1 locked object заблокований об’єкт (1) +@msg {n} locked objects заблоковані об’єкти ({n}) +@msg 1 hidden object прихований об’єкт (1) +@msg {n} hidden objects приховані об’єкти ({n}) @msg select at least two objects виділіть як мінімум два об’єкти @msg exit isolation mode first спочатку вийдіть з режиму ізоляції @msg select objects, not layers виділіть об’єкти, а не шари diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 10b8ea281..af6e5a15f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4625,6 +4625,13 @@ theme Appearance Giao diện @msg select an Image Trace object hãy chọn đối tượng vector hóa ảnh @msg select an object in perspective hãy chọn đối tượng trong phối cảnh @msg select artwork (or a colour group) to recolor hãy chọn tác phẩm (hoặc nhóm màu) để tô lại +@msg Recolor completed; {locked} and {hidden} were left unchanged Đã tô màu lại xong; không thay đổi: {locked} và {hidden} +@msg Recolor completed; {objects} were left unchanged Đã tô màu lại xong; không thay đổi: {objects} +@msg Recolor completed; {objects} was left unchanged Đã tô màu lại xong; không thay đổi: {objects} +@msg 1 locked object 1 đối tượng bị khóa +@msg {n} locked objects {n} đối tượng bị khóa +@msg 1 hidden object 1 đối tượng bị ẩn +@msg {n} hidden objects {n} đối tượng bị ẩn @msg select at least two objects hãy chọn ít nhất hai đối tượng @msg exit isolation mode first hãy thoát chế độ cô lập trước @msg select objects, not layers hãy chọn đối tượng, không phải lớp diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ac80bc510..10594947d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4145,6 +4145,13 @@ imageTrace Outline 轮廓 @msg print settings are not an object 打印设置不是对象 @msg no colour group `{g}` 没有颜色组 `{g}` @msg select artwork (or a colour group) to recolor 请选择要重新着色的图稿 (或颜色组) +@msg Recolor completed; {locked} and {hidden} were left unchanged 重新着色已完成;未更改:{locked}和{hidden} +@msg Recolor completed; {objects} were left unchanged 重新着色已完成;未更改:{objects} +@msg Recolor completed; {objects} was left unchanged 重新着色已完成;未更改:{objects} +@msg 1 locked object 1 个锁定的对象 +@msg {n} locked objects {n} 个锁定的对象 +@msg 1 hidden object 1 个隐藏的对象 +@msg {n} hidden objects {n} 个隐藏的对象 @msg Downloaded {_1} file(s) 已下载 {_1} 个文件 @msg Preview in Browser needs the desktop app (the web downloads files instead) “在浏览器中预览”需要桌面应用 (网页版会改为下载文件) @msg no file system here 此处没有文件系统 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f96254830..b75691a86 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2102,6 +2102,13 @@ file-filter Templates 範本 @msg print settings are not an object 列印設定不是物件 @msg no colour group `{g}` 沒有顏色群組 `{g}` @msg select artwork (or a colour group) to recolor 請選取要重新上色的圖稿 (或顏色群組) +@msg Recolor completed; {locked} and {hidden} were left unchanged 重新上色已完成;未變更:{locked}和{hidden} +@msg Recolor completed; {objects} were left unchanged 重新上色已完成;未變更:{objects} +@msg Recolor completed; {objects} was left unchanged 重新上色已完成;未變更:{objects} +@msg 1 locked object 1 個鎖定的物件 +@msg {n} locked objects {n} 個鎖定的物件 +@msg 1 hidden object 1 個隱藏的物件 +@msg {n} hidden objects {n} 個隱藏的物件 @msg Downloaded {_1} file(s) 已下載 {_1} 個檔案 @msg Preview in Browser needs the desktop app (the web downloads files instead) 「在瀏覽器中預覽」需要桌面應用程式 (網頁版會改為下載檔案) @msg no file system here 此處沒有檔案系統 diff --git a/docs/mcp.md b/docs/mcp.md index 6c33e8dc3..d7caff448 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1754,6 +1754,15 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh `intensity`, a number from -100 to 100 (a number outside the range counts as its nearest end). Without one, or with a value that is not a number, it answers `` missing number `intensity` `` and changes nothing. +The reply of `recolor.apply` and of every command above except the Blend commands has `notChanged: {locked, hidden}`. +When the selection is exactly what `select.all` takes (in isolation mode, what it takes inside the isolated group), it +counts the objects `select.all` left out whose colors the command would have changed: locked or hidden objects, the +objects of locked or hidden layers and sublayers, and objects on locked artboards (counted as locked). An object that +is both hidden and locked counts as hidden. Template layers' art is not counted. Locked and hidden objects inside a +selected group are recolored and not counted. With any other selection, or with `ids`, both counts are 0. When a count +is above 0 the reply also has a `warning`, which the app shows in the status bar, for example +`Recolor completed; 3 locked objects and 1 hidden object were left unchanged`. + ```json {"name":"run_command","arguments":{"command":"recolor.colors","params":{}}} {"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#0055ff"},"includeImages":false}}} From 8b63892265337fec6a98f99754bfe4d36afa4008 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 01:37:29 -0700 Subject: [PATCH 10/36] Linux: drop the xwayland import that the main merge left unused in titlebar.rs --- apps/vectorcraft/src/titlebar.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 5ff29657f..280965456 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -1,6 +1,4 @@ use super::window; -#[cfg(target_os = "linux")] -use super::xwayland; /// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets /// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches From e11e7a39d9d7b512fe97ee843f3c3da0bb741d46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:02:03 -0700 Subject: [PATCH 11/36] Recolor: count left-out art by the colors it shows A recolor of everything counted a locked or hidden object as left unchanged when the pass would only have linked it, or the tiles of a pattern it is filled with, to a swatch of the same color. With `group`, it also counted one as changed when the group's rewrite only removed its link to a dropped swatch, and as unchanged when the rewrite recolored a pattern it is filled with. The trial pass now leaves out changes to swatch links alone, patterns' tiles included, and the count compares the colors the object shows, its patterns' tiles included, before the recolor and after the rewrite. --- crates/engine/src/cmd/colorcmds.rs | 48 ++++++++++++++++-- crates/engine/src/cmd/recolor.rs | 37 +++++++++++--- crates/engine/src/tests/recolor.rs | 79 ++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index f068b41b5..3b0c3f788 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -218,10 +218,29 @@ impl<'a> Recolor<'a> { Self { links: self.links, ..Self::new(self.scope, self.f) } } - /// The entries of `objects` (ids, each with a flag) whose object this pass would change. The - /// document stays as it is. + /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other + /// than by swatch links alone, its patterns' tiles included. The document stays as it is. pub(crate) fn would_change(mut self, d: &Document, objects: &[(NodeId, T)]) -> Vec<(NodeId, T)> { - objects.iter().copied().filter(|(id, _)| d.node(*id).is_some_and(|n| self.subtree(d, n).is_some())).collect() + let tried: Vec<(&Node, Node, T)> = + objects.iter().filter_map(|&(id, flag)| d.node(id).and_then(|n| Some((n, self.subtree(d, n)?, flag)))).collect(); + let same = self.link_only_copies(d); + tried.into_iter().filter_map(|(n, m, flag)| (without_links(m, &same) != without_links(n.clone(), &same)).then_some((n.id, flag))).collect() + } + + /// The patterns this pass has copied whose copy differs from the original only by swatch + /// links: the copy's name → the original's. + fn link_only_copies(&self, d: &Document) -> HashMap { + let none = HashMap::new(); + let art = |v: &[Arc]| v.iter().map(|n| without_links((**n).clone(), &none)).collect::>(); + let copies: HashMap<&str, &PatternDef> = self.new_patterns.iter().map(|p| (p.name.as_str(), p)).collect(); + self.patterns + .iter() + .filter_map(|(old, new)| { + let new = new.as_ref()?; + let copy = copies.get(new.as_str())?; + (art(&d.pattern(old)?.art) == art(©.art)).then(|| (new.clone(), old.clone())) + }) + .collect() } /// Recolour the objects `ids` and everything inside them (each once), add the new images and @@ -324,6 +343,29 @@ impl<'a> Recolor<'a> { } } +/// `n` with the swatch links of its paints and its children's removed, each color kept as it +/// shows, and each pattern named in `same` (a copy → its original) by its original's name. +fn without_links(mut n: Node, same: &HashMap) -> Node { + for p in vectorcraft_doc::swatches::node_paints_mut(&mut n) { + map_links(p, &|_, link, _| link.take().is_some()); + match p { + Paint::Gradient(g) => g.swatch = None, + Paint::Pattern { pattern, .. } => { + if let Some(original) = same.get(pattern.as_str()) { + *pattern = original.clone(); + } + } + _ => {} + } + } + if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + *c = Arc::new(without_links((**c).clone(), same)); + } + } + n +} + /// What a pass over document `d` does to each colour the shared visitor meets. struct Visit<'p, 'a> { pass: &'p mut Recolor<'a>, diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 4a29afd4b..f5fdeb122 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -116,6 +116,27 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> used.into_iter().map(|(_, u)| u).collect() } +/// The colors `n` shows, in paint order ([`node_colors`]), then the colors of the tiles of the +/// patterns it uses as fills or strokes. +fn shown_colors(d: &Document, n: &Node) -> Vec { + let mut colors = vec![]; + node_colors(n, &mut |c, _| colors.push(*c)); + let mut patterns: Vec<&str> = vec![]; + n.walk(&mut |m| { + for p in node_paints(m) { + if let Paint::Pattern { pattern, .. } = p + && !patterns.contains(&pattern.as_str()) + { + patterns.push(pattern); + } + } + }); + for def in patterns.iter().filter_map(|p| d.pattern(p)) { + def.art.iter().for_each(|a| node_colors(a, &mut |c, _| colors.push(*c))); + } + colors +} + /// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids` /// (each checked) or else the selection, with everything they hold; with nothing selected, the /// objects Select All takes and the objects inside them, leaving out every hidden or locked object @@ -501,18 +522,22 @@ fn apply(s: &mut Session, p: &Value) -> Result { let map = ColorMap::new(&rows, method, own_model, &links, kept); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); - // The objects left out whose colors the pass would change, as they are now. The trial - // pass does not count towards the note on color models. - let before: Vec<(Node, bool)> = Recolor::with_links(scope, &f, &lf) + // The objects left out whose colors the pass would change, with the colors they show now. + // The trial pass does not count towards the note on color models. + let before: Vec<(NodeId, Vec, bool)> = Recolor::with_links(scope, &f, &lf) .would_change(d, &left) .into_iter() - .filter_map(|(id, hidden)| Some((d.node(id)?.clone(), hidden))) + .filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden))) .collect(); map.converted.set(false); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; - // Rewriting the group's swatches can change art linked to them, left out or not. - let not_changed = NotChanged::of(before.iter().filter(|(n, _)| d.node(n.id) == Some(n)).map(|(_, hidden)| *hidden)); + // Rewriting the group's swatches can change the colors of art linked to them, left out or + // not, and of the patterns it uses. Art that only loses its link keeps its colors and is + // counted. + let not_changed = NotChanged::of( + before.iter().filter(|(id, colors, _)| d.node(*id).is_some_and(|now| shown_colors(d, now) == *colors)).map(|(_, _, hidden)| *hidden), + ); Ok((changed, edit, added, map.converted.get(), not_changed)) })?; let mut out = json!({ "changed": changed, "swatches": added }); diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index b9b4377e1..8e9c8da46 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -608,6 +608,85 @@ fn recolor_of_everything_counts_the_locked_and_hidden_objects_it_left_out() { } } +#[test] +fn recolor_of_everything_counts_locked_art_that_only_loses_its_group_swatch_link() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]})); + let names: Vec = + s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect(); + for name in &names { + run(&mut s, "swatch.edit", json!({"name": name, "global": true})); + } + let open = rect(&mut s, json!("#000000")); + let locked = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [open.0, locked.0], "swatch": names[1]})); + set_props(&mut s, &[locked], json!({"locked": true})); + select_all(&mut s); + // The group is rewritten with one color, so its second swatch goes and the locked art only + // loses its link to it. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#123456"]})); + assert_eq!(fill(&s, open).to_hex(), "#00ff00"); + assert_eq!(fill(&s, locked).to_hex(), "#ff0000", "the locked art keeps its color"); + assert_eq!(r["notChanged"], json!({"locked": 1, "hidden": 0}), "{r}"); +} + +#[test] +fn recolor_of_everything_does_not_count_locked_art_whose_pattern_the_group_rewrite_recolors() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]})); + let names: Vec = + s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect(); + for name in &names { + run(&mut s, "swatch.edit", json!({"name": name, "global": true})); + } + // "Dots": a tile linked to the group's second swatch. + let tile = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]})); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex(); + assert_eq!(tile_hex(&s), "#ff0000"); + let locked = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Dots"})); + set_props(&mut s, &[locked], json!({"locked": true})); + let open = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]})); + select_all(&mut s); + // The group's second swatch is rewritten with the row's new color, and the tile follows it. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]})); + assert_eq!(tile_hex(&s), "#00ff00", "{r}"); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); +} + +#[test] +fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch_link() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true})); + // "Dots": an unlinked red tile. + let tile = rect(&mut s, json!("#ff0000")); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let open = rect(&mut s, json!("#ff0000")); + let locked = rect(&mut s, json!("#ff0000")); + let dots = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [dots.0], "swatch": "Dots"})); + set_props(&mut s, &[locked, dots], json!({"locked": true})); + select_all(&mut s); + // The row keeps the color and snaps it to the document's global swatch of the same color, so + // the open art only takes the link. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"})); + let link = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() { + vectorcraft_color::Paint::Solid { swatch, .. } => swatch, + p => panic!("{p:?}"), + }; + assert!(link(&s, open).is_some(), "{r}"); + assert_eq!(fill(&s, locked).to_hex(), "#ff0000"); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + #[test] fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() { let mut s = session(); From 547b0aa2dd3d34780ff3fd9fc9b362992fce7e5c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:48:37 -0700 Subject: [PATCH 12/36] i18n: Catalan row for the VECTORCRAFT_TITLEBAR note in Preferences --- crates/ui-egui/src/i18n/ca.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4022efa29..da234a9c9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -303,6 +303,7 @@ Appearance panel Panell Aparença Applied Filters Filtres aplicats Applies the next time VectorCraft starts. S’aplica la propera vegada que s’iniciï VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mentre està definida, la variable d’entorn VECTORCRAFT_TITLEBAR determina la barra de títol. Applies when the linked file is relinked or updated. S’aplica quan el fitxer enllaçat es torna a enllaçar o s’actualitza. Apply Aplica Apply Brush Aplica el pinzell From 5a342685de8a2cdce0e0da982dcd8e259f68ee4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:49:10 -0700 Subject: [PATCH 13/36] i18n: Catalan rows for the recolor notes on objects left unchanged Catalan joined the complete-message languages on main. The test now looks for the English words as whole words, so the Catalan "objecte" is not read as "object". --- crates/ui-egui/src/i18n/ca.tsv | 7 +++++++ crates/ui-egui/src/i18n/tests.rs | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0eb9ab2c4..b81f917c9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5326,6 +5326,13 @@ @msg select an object in perspective selecciona un objecte en perspectiva @msg select an Objects on Path group selecciona un grup d’objectes sobre un traçat @msg select artwork (or a colour group) to recolor selecciona la il·lustració (o un grup de colors) per recolorejar +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloració completada; sense canvis: {locked} i {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloració completada; sense canvis: {objects} +@msg Recolor completed; {objects} was left unchanged Recoloració completada; sense canvis: {objects} +@msg 1 locked object 1 objecte bloquejat +@msg {n} locked objects {n} objectes bloquejats +@msg 1 hidden object 1 objecte ocult +@msg {n} hidden objects {n} objectes ocults @msg select at least two objects selecciona almenys dos objectes @msg select objects, not layers selecciona objectes, no capes @msg select one object selecciona un objecte diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f3beb1cc6..78c9eee25 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1495,8 +1495,9 @@ fn recolor_notes_on_objects_left_unchanged_translate_with_their_counts() { let lang = Lang::from_code(code).expect("registered"); for (note, counts) in notes { let shown = message(lang, note); - let english = ["completed", "locked", "hidden", "object", "unchanged"]; - assert!(english.iter().all(|w| !shown.contains(w)), "{code}: {shown}"); + // Whole words, so that a translation such as Catalan's "objecte" is not read as English. + let english = ["completed", "locked", "hidden", "object", "objects", "unchanged"]; + assert!(shown.split(|c: char| !c.is_alphabetic()).all(|w| !english.contains(&w)), "{code}: {shown}"); assert!(counts.iter().all(|n| shown.contains(n)), "{code}: {shown}"); } } From a90ae1e9a975087dea5471240c54f57c1fbf94cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:49:10 -0700 Subject: [PATCH 14/36] Recolor: count left-out art by the colors of nested pattern tiles too A left-out object whose pattern's tile uses another pattern showed the inner tile's colors without them being compared, so a group rewrite that recolored the inner tile still counted the object as left unchanged. The comparison now follows the patterns tiles use, each once. --- crates/engine/src/cmd/recolor.rs | 27 ++++++++++++++++-------- crates/engine/src/tests/recolor.rs | 33 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 0d07e2a2c..a3ce988bb 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -119,24 +119,35 @@ fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> } /// The colors `n` shows, in paint order ([`node_colors`]), then the colors of the tiles of the -/// patterns it uses as fills or strokes. -fn shown_colors(d: &Document, n: &Node) -> Vec { +/// patterns it uses as fills or strokes, and of the patterns those tiles use (each pattern once). +fn shown_colors<'a>(d: &'a Document, n: &'a Node) -> Vec { let mut colors = vec![]; node_colors(n, &mut |c, _| colors.push(*c)); let mut patterns: Vec<&str> = vec![]; + pattern_names(n, &mut patterns); + let mut next = 0; + while let Some(&name) = patterns.get(next) { + next += 1; + for a in d.pattern(name).map(|def| def.art.as_slice()).unwrap_or_default() { + node_colors(a, &mut |c, _| colors.push(*c)); + pattern_names(a, &mut patterns); + } + } + colors +} + +/// Adds the names of the patterns `n` and the objects inside it use as fills or strokes to `names`, +/// leaving out names already there. +fn pattern_names<'a>(n: &'a Node, names: &mut Vec<&'a str>) { n.walk(&mut |m| { for p in node_paints(m) { if let Paint::Pattern { pattern, .. } = p - && !patterns.contains(&pattern.as_str()) + && !names.contains(&pattern.as_str()) { - patterns.push(pattern); + names.push(pattern); } } }); - for def in patterns.iter().filter_map(|p| d.pattern(p)) { - def.art.iter().for_each(|a| node_colors(a, &mut |c, _| colors.push(*c))); - } - colors } /// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids` diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 17a30417e..72f8f2f64 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -659,6 +659,39 @@ fn recolor_of_everything_does_not_count_locked_art_whose_pattern_the_group_rewri assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); } +#[test] +fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_the_group_rewrite_recolors() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#2878f0", "#ff0000"]})); + let names: Vec = + s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Brand").unwrap().swatches.iter().map(|w| w.name.clone()).collect(); + for name in &names { + run(&mut s, "swatch.edit", json!({"name": name, "global": true})); + } + // "Dots": a tile linked to the group's second swatch; "Rings": a tile filled with "Dots". + let tile = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": names[1]})); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let outer = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"})); + run(&mut s, "select.set", json!({"ids": [outer.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40})); + run(&mut s, "object.pattern.done", json!({})); + let tile_hex = |s: &Session| s.doc().unwrap().doc.pattern("Dots").unwrap().art[0].appearance.fill_paint().color().unwrap().to_hex(); + let locked = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [locked.0], "swatch": "Rings"})); + set_props(&mut s, &[locked], json!({"locked": true})); + let open = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [open.0], "swatch": names[1]})); + select_all(&mut s); + // The rewrite recolors the inner tile, which the locked art shows through "Rings". + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#00ff00"}, "group": "Brand", "groupColors": ["#2878f0", "#00ff00"]})); + assert_eq!(tile_hex(&s), "#00ff00", "{r}"); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); +} + #[test] fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch_link() { let mut s = session(); From 8da340a7b9e2c39c1a1f4fbf0b41b06281e5f03f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:59:21 -0700 Subject: [PATCH 15/36] Recolor: a nested pattern copy that only takes swatch links is not a color change When a tile's inner pattern only took swatch links, the outer pattern's copy still named the inner copy, so the trial pass counted a locked object filled with the outer pattern as left unchanged. Copies are now compared with the link-only copies they use mapped back to their originals, repeated until no more copies qualify. --- crates/engine/src/cmd/colorcmds.rs | 26 +++++++++++++++++++------- crates/engine/src/tests/recolor.rs | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 3b0c3f788..a7e42899a 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -228,19 +228,31 @@ impl<'a> Recolor<'a> { } /// The patterns this pass has copied whose copy differs from the original only by swatch - /// links: the copy's name → the original's. + /// links: the copy's name → the original's. A copy whose tile uses other copies counts when + /// those copies do. fn link_only_copies(&self, d: &Document) -> HashMap { - let none = HashMap::new(); - let art = |v: &[Arc]| v.iter().map(|n| without_links((**n).clone(), &none)).collect::>(); + let art = |v: &[Arc], same: &HashMap| v.iter().map(|n| without_links((**n).clone(), same)).collect::>(); let copies: HashMap<&str, &PatternDef> = self.new_patterns.iter().map(|p| (p.name.as_str(), p)).collect(); - self.patterns + let pairs: Vec<(&String, &String, &PatternDef, &PatternDef)> = self + .patterns .iter() .filter_map(|(old, new)| { let new = new.as_ref()?; - let copy = copies.get(new.as_str())?; - (art(&d.pattern(old)?.art) == art(©.art)).then(|| (new.clone(), old.clone())) + Some((new, old, d.pattern(old)?, *copies.get(new.as_str())?)) }) - .collect() + .collect(); + let mut same = HashMap::new(); + loop { + let found: Vec<(String, String)> = pairs + .iter() + .filter(|(new, _, original, copy)| !same.contains_key(*new) && art(&original.art, &same) == art(©.art, &same)) + .map(|(new, old, _, _)| ((*new).clone(), (*old).clone())) + .collect(); + if found.is_empty() { + return same; + } + same.extend(found); + } } /// Recolour the objects `ids` and everything inside them (each once), add the new images and diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 72f8f2f64..b4b22d151 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -720,6 +720,31 @@ fn recolor_of_everything_does_not_count_locked_art_that_would_only_take_a_swatch assert!(r.get("warning").is_none(), "{r}"); } +#[test] +fn recolor_of_everything_does_not_count_locked_art_whose_nested_pattern_would_only_take_a_swatch_link() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Red", "color": "#ff0000", "global": true})); + // "Dots": an unlinked red tile; "Rings": a tile filled with "Dots". + let tile = rect(&mut s, json!("#ff0000")); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let outer = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [outer.0], "swatch": "Dots"})); + run(&mut s, "select.set", json!({"ids": [outer.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Rings", "width": 40, "height": 40})); + run(&mut s, "object.pattern.done", json!({})); + rect(&mut s, json!("#ff0000")); + let rings = rect(&mut s, json!("#000000")); + run(&mut s, "paint.setFill", json!({"ids": [rings.0], "swatch": "Rings"})); + set_props(&mut s, &[rings], json!({"locked": true})); + select_all(&mut s); + // The inner tile would only take the link to the document's red swatch. + let r = run(&mut s, "recolor.apply", json!({"map": {"#ff0000": "#ff0000"}, "limitTo": "document"})); + assert_eq!(r["notChanged"], json!({"locked": 0, "hidden": 0}), "{r}"); + assert!(r.get("warning").is_none(), "{r}"); +} + #[test] fn locked_and_hidden_members_of_a_selected_group_are_recolored_and_not_counted() { let mut s = session(); From bdbf6cc57533cf0f00bf9b68863721dfca12b888 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 06:00:04 -0700 Subject: [PATCH 16/36] i18n: no-break spaces before ; and : in the French recolor notes --- crates/ui-egui/src/i18n/fr.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c6fd14a6..57705bdc0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4306,9 +4306,9 @@ axis Both Les deux @msg select an image sélectionnez une image @msg select an object in perspective sélectionnez un objet en perspective @msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs -@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} -@msg Recolor completed; {objects} were left unchanged Recoloration terminée ; sans changement : {objects} -@msg Recolor completed; {objects} was left unchanged Recoloration terminée ; sans changement : {objects} +@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden} +@msg Recolor completed; {objects} were left unchanged Recoloration terminée ; sans changement : {objects} +@msg Recolor completed; {objects} was left unchanged Recoloration terminée ; sans changement : {objects} @msg 1 locked object 1 objet verrouillé @msg {n} locked objects {n} objets verrouillés @msg 1 hidden object 1 objet masqué From 6118e7afba56eb00d37bdba0264e69e6b3d71265 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 21:53:19 -0700 Subject: [PATCH 17/36] File formats: JPEG XL reads Exif resolution and bounds the container copy A JPEG XL container's Exif box now sizes opened and placed images by its resolution tags, as TIFF and WebP Exif do; a bare codestream stays at 72 ppi. The pixel boxes copied out of a container are limited to the raster allowance and reserved fallibly, so a large payload is refused with "the JPEG XL container is too large to read" before decoding. --- crates/engine/src/cmd/fileio/jxlread.rs | 19 +++++--- crates/engine/src/cmd/fileio/ppi.rs | 30 +++++++++++- crates/engine/src/cmd/fileio/tests_jxlread.rs | 48 ++++++++++++++++++- crates/ui-egui/src/i18n/ca.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/ka.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/tr.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/file-format-parity.md | 2 +- 25 files changed, 111 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 021cd5909..011af6a0f 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -42,9 +42,11 @@ pub fn is_jxl(bytes: &[u8]) -> bool { /// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors /// are in when they are not converted to sRGB ([`kept_profile`]). Fails, without decoding the /// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too -/// large to draw when the decoder's buffers for its pixels need more than they are allowed. +/// large to draw when the decoder's buffers for its pixels need more than they are allowed. A +/// container whose selected boxes exceed `max_alloc` or [`HEADER_BUFFERS`], whichever is greater, +/// is refused with "the JPEG XL container is too large to read". pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), String> { - let pixel_boxes = pixel_boxes(bytes); + let pixel_boxes = pixel_boxes(bytes, max_alloc.max(HEADER_BUFFERS as u64))?; let bytes = pixel_boxes.as_deref().unwrap_or(bytes); // The decoder reads the header first, with buffers for the header alone. let (head, _) = header(bytes, HEADER_BUFFERS, None)?; @@ -169,9 +171,10 @@ pub(crate) fn to_rgba( /// A container's signature and the boxes the decoder reads pixels from (`ftyp`, `jxll`, `jxlc` and /// `jxlp`). Every other box is left out: Exif, XMP, JPEG reconstruction data and Brotli-compressed /// `brob` boxes, which the decoder would otherwise store and unpack without a size limit. A box -/// cut short ends the file. `None` for a bare codestream. -fn pixel_boxes(bytes: &[u8]) -> Option> { - let mut rest = bytes.strip_prefix(&CONTAINER)?; +/// cut short ends the file. Selected boxes exceeding `max_copy` are refused with "the JPEG XL +/// container is too large to read". `None` for a bare codestream. +pub(super) fn pixel_boxes(bytes: &[u8], max_copy: u64) -> Result>, String> { + let Some(mut rest) = bytes.strip_prefix(&CONTAINER) else { return Ok(None) }; let mut out = CONTAINER.to_vec(); while let (Some(size), Some(ty)) = (rest.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()), rest.get(4..8)) { let (head, len) = match u32::from_be_bytes(size) { @@ -188,11 +191,15 @@ fn pixel_boxes(bytes: &[u8]) -> Option> { let len = usize::try_from(len).unwrap_or(usize::MAX).min(rest.len()); let (b, after) = rest.split_at(len); if matches!(ty, b"ftyp" | b"jxll" | b"jxlc" | b"jxlp") { + if (out.len() as u64).saturating_add(b.len() as u64) > max_copy { + return Err("the JPEG XL container is too large to read".into()); + } + out.try_reserve_exact(b.len()).map_err(|_| "the JPEG XL container is too large to read")?; out.extend_from_slice(b); } rest = after; } - Some(out) + Ok(Some(out)) } /// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 61b741756..cc5fe1aa3 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -1,5 +1,5 @@ //! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and -//! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX +//! WebP and JPEG XL EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX //! header's HDpi and VDpi. Place //! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it //! as a `pHYs` chunk. @@ -16,6 +16,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { jpeg(bytes) } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { tiff(bytes) + } else if super::jxlread::is_jxl(bytes) { + jxl(bytes) } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { webp(bytes) } else if bytes.starts_with(b"BM") { @@ -153,6 +155,32 @@ fn webp(b: &[u8]) -> Option<(f64, f64)> { None } +fn jxl(b: &[u8]) -> Option<(f64, f64)> { + if !b.starts_with(&[0, 0, 0, 12, b'J', b'X', b'L', b' ', 13, 10, 135, 10]) { + return None; + } + let mut at = 12; + while at < b.len() { + let size = be32(b, at)?; + let ty = slice(b, at.checked_add(4)?, 4)?; + let (header, len) = match size { + 0 => (8, b.len().checked_sub(at)?), + 1 => (16, usize::try_from(u64::from_be_bytes(bytes(b, at.checked_add(8)?)?)).ok()?), + _ => (8, size as usize), + }; + if len < header { + return None; + } + let payload = slice(b, at.checked_add(header)?, len - header)?; + if ty == b"Exif" { + let offset = usize::try_from(be32(payload, 0)?).ok()?; + return tiff(payload.get(4usize.checked_add(offset)?..)?); + } + at = at.checked_add(len)?; + } + None +} + fn bmp(b: &[u8]) -> Option<(f64, f64)> { // BITMAPINFOHEADER (40 bytes or longer) at 14: pixels per metre at +24 and +28. if le32(b, 14)? < 40 { diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index 1c248f486..50255ae0a 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -6,7 +6,7 @@ use zune_core::bit_depth::BitDepth; use zune_core::colorspace::ColorSpace; use zune_core::options::EncoderOptions; -use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, to_rgba}; +use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, pixel_boxes, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -69,6 +69,52 @@ fn jxl_images_open_and_place_with_their_pixels_and_alpha() { assert!(open_filters().any(|f| f == ("JPEG XL", &["jxl"][..])) && place_filters().any(|f| f == ("JPEG XL", &["jxl"][..]))); } +#[test] +fn container_copy_stays_within_the_input_allowance() { + let f = container(&[0; 128]); + assert!(pixel_boxes(&f, 64).unwrap_err().contains("too large")); + assert_eq!(pixel_boxes(&f, f.len() as u64).unwrap().unwrap(), f); +} + +#[test] +fn container_exif_resolution_sizes_open_and_place() { + let bare = rgba8(); + let mut f = container(&[]); + f.truncate(32); + let mut exif = Vec::new(); + exif.extend(4u32.to_be_bytes()); + exif.extend(b"Exif"); + exif.extend(b"II*\0"); + exif.extend(8u32.to_le_bytes()); + exif.extend(3u16.to_le_bytes()); + for (tag, value) in [(282u16, 50u32), (283, 58), (296, 2)] { + exif.extend(tag.to_le_bytes()); + exif.extend((if tag == 296 { 3u16 } else { 5 }).to_le_bytes()); + exif.extend(1u32.to_le_bytes()); + exif.extend(value.to_le_bytes()); + } + exif.extend(0u32.to_le_bytes()); + for value in [144u32, 1, 72, 1] { + exif.extend(value.to_le_bytes()); + } + f.extend((8 + exif.len() as u32).to_be_bytes()); + f.extend(b"Exif"); + f.extend(&exif); + f.extend((8 + bare.len() as u32).to_be_bytes()); + f.extend(b"jxlc"); + f.extend(&bare); + assert_eq!(super::ppi::resolution(&f), Some((144.0, 72.0))); + assert_eq!(super::ppi::resolution(&bare), None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap(); + let rect = s.doc().unwrap().doc.artboards[0].rect; + assert_eq!((rect.width(), rect.height()), (1.5, 2.0)); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = s.execute("file.place", &json!({"name": "resolved.jxl", "dataBase64": b64(&f)})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(1.5), Some(2.0))); + assert_eq!(super::ppi::resolution(&s.doc().unwrap().doc.images.values().next().unwrap().bytes), Some((144.0, 72.0))); +} + /// A `.jxl` file copied in a file manager pastes as its image. #[test] fn a_copied_jxl_file_pastes_as_its_image() { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4d240a2a4..c0db3edc6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5941,6 +5941,7 @@ breakText Glyphs Glifs @msg `kind` must be "line" or "paragraph", not {other} `kind` ha de ser "line" o "paragraph", no {other} @msg `align` must be "left", "center" or "right", not {other} `align` ha de ser "left", "center" o "right", no {other} @msg the JPEG XL file is cut short el fitxer JPEG XL està truncat +@msg the JPEG XL container is too large to read el contenidor JPEG XL és massa gran per llegir-lo @msg CMYK JPEG XL images can't be read les imatges JPEG XL en CMYK no es poden llegir @msg the JPEG XL file can't be read: {e} no es pot llegir el fitxer JPEG XL: {e} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 68b14606e..6016cf5fb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4888,6 +4888,7 @@ imageTrace Outline Obrys @msg the PSD file's header is damaged hlavička souboru PSD je poškozená @msg the PSD file's image data can't be read: {e} obrazová data souboru PSD nelze přečíst: {e} @msg the JPEG XL file is cut short soubor JPEG XL je useknutý +@msg the JPEG XL container is too large to read kontejner JPEG XL je příliš velký pro načtení @msg CMYK JPEG XL images can't be read obrázky JPEG XL v režimu CMYK nelze přečíst @msg the JPEG XL file can't be read: {e} soubor JPEG XL nelze přečíst: {e} @msg Imported library {name} Importována knihovna {name} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e629f19c0..439118a3f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4625,6 +4625,7 @@ theme Appearance Erscheinungsbild @msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden @msg the JPEG XL file is cut short die JPEG-XL-Datei ist unvollständig +@msg the JPEG XL container is too large to read der JPEG-XL-Container ist zu groß zum Lesen @msg CMYK JPEG XL images can't be read JPEG-XL-Bilder im CMYK-Modus können nicht gelesen werden @msg the JPEG XL file can't be read: {e} die JPEG-XL-Datei kann nicht gelesen werden: {e} Value Axis Werteachse diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f5a1e83e5..c972eae23 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4569,6 +4569,7 @@ theme Appearance Εμφάνιση @msg the PSD file's header is damaged η κεφαλίδα του αρχείου PSD είναι κατεστραμμένη @msg the PSD file's image data can't be read: {e} τα δεδομένα εικόνας PSD δεν μπορούν να διαβαστούν: {e} @msg the JPEG XL file is cut short το αρχείο JPEG XL είναι περικομμένο +@msg the JPEG XL container is too large to read το κοντέινερ JPEG XL είναι πολύ μεγάλο για ανάγνωση @msg CMYK JPEG XL images can't be read οι εικόνες JPEG XL σε CMYK δεν διαβάζονται @msg the JPEG XL file can't be read: {e} το αρχείο JPEG XL δεν μπορεί να διαβαστεί: {e} Create New Fields by Δημιουργία νέων πεδίων κατά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e78c5783d..3778d95bf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4609,6 +4609,7 @@ axis Both Ambos @msg the PSD file's header is damaged el encabezado del archivo PSD está dañado @msg the PSD file's image data can't be read: {e} no se pueden leer los datos de imagen del archivo PSD: {e} @msg the JPEG XL file is cut short el archivo JPEG XL está incompleto +@msg the JPEG XL container is too large to read el contenedor JPEG XL es demasiado grande para leerlo @msg CMYK JPEG XL images can't be read las imágenes JPEG XL en CMYK no se pueden leer @msg the JPEG XL file can't be read: {e} no se puede leer el archivo JPEG XL: {e} Video Video diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8b7c8df3b..fadaa1bbf 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4791,6 +4791,7 @@ imageTrace Outline Ääriviiva @msg the PSD file's header is damaged tämän PSD-tiedoston otsake on vaurioitunut @msg the PSD file's image data can't be read: {e} tämän PSD-tiedoston kuvadataa ei voi lukea: {e} @msg the JPEG XL file is cut short tämä JPEG XL -tiedosto on katkennut +@msg the JPEG XL container is too large to read JPEG XL -säiliö on liian suuri luettavaksi @msg CMYK JPEG XL images can't be read CMYK-tilassa olevia JPEG XL -kuvia ei voi lukea @msg the JPEG XL file can't be read: {e} tätä JPEG XL -tiedostoa ei voi lukea: {e} @msg Imported library {name} Tuotiin kirjasto {name} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 683339881..bcd1a43dd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4620,6 +4620,7 @@ axis Both Les deux @msg the PSD file's header is damaged l'en-tête du fichier PSD est endommagé @msg the PSD file's image data can't be read: {e} les données d'image du fichier PSD ne peuvent pas être lues : {e} @msg the JPEG XL file is cut short le fichier JPEG XL est tronqué +@msg the JPEG XL container is too large to read le conteneur JPEG XL est trop volumineux pour être lu @msg CMYK JPEG XL images can't be read les images JPEG XL en CMJN ne peuvent pas être lues @msg the JPEG XL file can't be read: {e} le fichier JPEG XL ne peut pas être lu : {e} Video Vidéo diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b9423f07c..e15532d40 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5122,6 +5122,7 @@ imageTrace Outline Körvonal @msg the PSD file's header is damaged a PSD-fájl fejléce sérült @msg the PSD file's image data can't be read: {e} a PSD-fájl képadatai nem olvashatók: {e} @msg the JPEG XL file is cut short a JPEG XL-fájl csonka +@msg the JPEG XL container is too large to read a JPEG XL-konténer túl nagy az olvasáshoz @msg CMYK JPEG XL images can't be read CMYK módú JPEG XL-képek nem olvashatók @msg the JPEG XL file can't be read: {e} a JPEG XL-fájl nem olvasható: {e} @msg the PSD file's image data uses an unknown compression ({m}) a PSD-fájl képadatai ismeretlen tömörítést használnak ({m}) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3b6caa2e3..15e4aaf24 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4649,6 +4649,7 @@ axis Both Entrambi @msg the PSD file's header is damaged l'intestazione del file PSD è danneggiata @msg the PSD file's image data can't be read: {e} i dati immagine del file PSD non possono essere letti: {e} @msg the JPEG XL file is cut short il file JPEG XL è troncato +@msg the JPEG XL container is too large to read il contenitore JPEG XL è troppo grande per essere letto @msg CMYK JPEG XL images can't be read le immagini JPEG XL in CMYK non possono essere lette @msg the JPEG XL file can't be read: {e} il file JPEG XL non può essere letto: {e} Video Video diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 558135e46..42a9f6f26 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4694,6 +4694,7 @@ swatch Bright Violet ビビッドバイオレット @msg the PSD file's header is damaged PSD ファイルのヘッダーが破損しています @msg the PSD file's image data can't be read: {e} PSD ファイルの画像データを読み込めません:{e} @msg the JPEG XL file is cut short JPEG XL ファイルが途中で切れています +@msg the JPEG XL container is too large to read JPEG XL コンテナが大きすぎて読み込めません @msg CMYK JPEG XL images can't be read CMYK の JPEG XL 画像は読み込めません @msg the JPEG XL file can't be read: {e} JPEG XL ファイルを読み込めません:{e} Video ビデオ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a6b49e906..46dbc8f50 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4328,6 +4328,7 @@ font-technology Variable ცვლადი @msg the PSD file's header is damaged PSD ფაილის სათაური დაზიანებულია @msg the PSD file's image data can't be read: {e} PSD ფაილის გამოსახულების მონაცემების წაკითხვა ვერ ხერხდება: {e} @msg the JPEG XL file is cut short JPEG XL ფაილი შეკვეცილია +@msg the JPEG XL container is too large to read JPEG XL კონტეინერი წასაკითხად ზედმეტად დიდია @msg CMYK JPEG XL images can't be read CMYK JPEG XL გამოსახულებების წაკითხვა ვერ ხერხდება @msg the JPEG XL file can't be read: {e} JPEG XL ფაილის წაკითხვა ვერ ხერხდება: {e} @msg Imported library {name} შემოტანილია ბიბლიოთეკა {name} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 597ac7133..002427ab3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4619,6 +4619,7 @@ theme Appearance 외관 @msg the PSD file's header is damaged PSD 파일의 헤더가 손상되었습니다 @msg the PSD file's image data can't be read: {e} PSD 파일의 이미지 데이터를 읽을 수 없습니다: {e} @msg the JPEG XL file is cut short JPEG XL 파일이 잘려 있습니다 +@msg the JPEG XL container is too large to read JPEG XL 컨테이너가 너무 커서 읽을 수 없습니다 @msg CMYK JPEG XL images can't be read CMYK JPEG XL 이미지는 읽을 수 없습니다 @msg the JPEG XL file can't be read: {e} JPEG XL 파일을 읽을 수 없습니다: {e} Video 비디오 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index aa7262a6d..4adf67150 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4715,6 +4715,7 @@ imageTrace Outline Kontur @msg the PSD file's header is damaged nagłówek pliku PSD jest uszkodzony @msg the PSD file's image data can't be read: {e} nie można odczytać danych obrazu pliku PSD: {e} @msg the JPEG XL file is cut short plik JPEG XL jest ucięty +@msg the JPEG XL container is too large to read kontener JPEG XL jest zbyt duży, aby go odczytać @msg CMYK JPEG XL images can't be read nie można odczytać obrazów JPEG XL w trybie CMYK @msg the JPEG XL file can't be read: {e} nie można odczytać pliku JPEG XL: {e} @msg Imported library {name} Zaimportowano bibliotekę {name} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9eb5cdfb7..90179148e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4152,6 +4152,7 @@ axis Both Ambos @msg the PSD file's header is damaged o cabeçalho do arquivo PSD está danificado @msg the PSD file's image data can't be read: {e} os dados de imagem do arquivo PSD não podem ser lidos: {e} @msg the JPEG XL file is cut short o arquivo JPEG XL está incompleto +@msg the JPEG XL container is too large to read o contêiner JPEG XL é grande demais para ser lido @msg CMYK JPEG XL images can't be read imagens JPEG XL em CMYK não podem ser lidas @msg the JPEG XL file can't be read: {e} o arquivo JPEG XL não pode ser lido: {e} @msg Imported library {name} Biblioteca {name} importada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index dfd359b2a..8e9e00167 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4647,6 +4647,7 @@ axis Both Оба @msg the PSD file's header is damaged заголовок файла PSD повреждён @msg the PSD file's image data can't be read: {e} не удаётся прочитать данные изображения файла PSD: {e} @msg the JPEG XL file is cut short файл JPEG XL обрезан +@msg the JPEG XL container is too large to read контейнер JPEG XL слишком велик для чтения @msg CMYK JPEG XL images can't be read изображения JPEG XL в режиме CMYK не читаются @msg the JPEG XL file can't be read: {e} не удаётся прочитать файл JPEG XL: {e} Video Видео diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c3cf3cd7a..c54c5c74b 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4526,6 +4526,7 @@ axis Both Båda @msg the PSD file's header is damaged PSD-filens huvud är skadat @msg the PSD file's image data can't be read: {e} PSD-filens bilddata kan inte läsas: {e} @msg the JPEG XL file is cut short JPEG XL-filen är avkortad +@msg the JPEG XL container is too large to read JPEG XL-behållaren är för stor för att läsas @msg CMYK JPEG XL images can't be read JPEG XL-bilder i CMYK kan inte läsas @msg the JPEG XL file can't be read: {e} JPEG XL-filen kan inte läsas: {e} Video Video diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f60aebad1..2ac252482 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4369,6 +4369,7 @@ axis Both Her İkisi @msg the PSD file's header is damaged PSD dosyasının başlığı hasarlı @msg the PSD file's image data can't be read: {e} PSD dosyasının görüntü verileri okunamıyor: {e} @msg the JPEG XL file is cut short JPEG XL dosyası kısa kesildi +@msg the JPEG XL container is too large to read JPEG XL kapsayıcısı okunamayacak kadar büyük @msg CMYK JPEG XL images can't be read CMYK JPEG XL görüntüleri okunamıyor @msg the JPEG XL file can't be read: {e} JPEG XL dosyası okunamıyor: {e} Video video diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5e7e78bb9..cac7bed15 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4633,6 +4633,7 @@ axis Both Обидві @msg the PSD file's header is damaged заголовок файлу PSD пошкоджено @msg the PSD file's image data can't be read: {e} не вдається прочитати дані зображення файлу PSD: {e} @msg the JPEG XL file is cut short файл JPEG XL обрізаний +@msg the JPEG XL container is too large to read контейнер JPEG XL завеликий для читання @msg CMYK JPEG XL images can't be read зображення JPEG XL у режимі CMYK не читаються @msg the JPEG XL file can't be read: {e} не вдається прочитати файл JPEG XL: {e} Video Відео diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dc62f4115..6deb1e112 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4779,6 +4779,7 @@ theme Appearance Giao diện @msg the PSD file's header is damaged tiêu đề tệp PSD bị hỏng @msg the PSD file's image data can't be read: {e} dữ liệu ảnh tệp PSD không thể đọc được: {e} @msg the JPEG XL file is cut short tệp JPEG XL bị cắt ngắn +@msg the JPEG XL container is too large to read vùng chứa JPEG XL quá lớn để đọc @msg CMYK JPEG XL images can't be read ảnh JPEG XL hệ màu CMYK không thể đọc được @msg the JPEG XL file can't be read: {e} tệp JPEG XL không thể đọc được: {e} @msg the PSD file's image data uses an unknown compression ({m}) dữ liệu ảnh tệp PSD dùng nén không xác định ({m}) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 38e928d78..4eaa08c29 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5180,6 +5180,7 @@ lighting Light 光源 Load EPS into Clipboard 将 EPS 载入剪贴板 @msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制 @msg the JPEG XL file is cut short JPEG XL 文件不完整 +@msg the JPEG XL container is too large to read JPEG XL 容器过大,无法读取 @msg CMYK JPEG XL images can't be read 无法读取 CMYK 模式的 JPEG XL 图像 @msg the JPEG XL file can't be read: {e} 无法读取 JPEG XL 文件:{e} @msg an image of {w} × {h} pixels is shown at reduced size {w} × {h} 像素的图像以缩小的尺寸显示 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2829fe60c..b4d4fe2a9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2341,6 +2341,7 @@ file-filter Templates 範本 @msg the PSD file's header is damaged PSD 檔頭已損毀 @msg the PSD file's image data can't be read: {e} 無法讀取 PSD 檔案的影像資料:{e} @msg the JPEG XL file is cut short JPEG XL 檔案不完整 +@msg the JPEG XL container is too large to read JPEG XL 容器過大,無法讀取 @msg CMYK JPEG XL images can't be read 無法讀取 CMYK 模式的 JPEG XL 影像 @msg the JPEG XL file can't be read: {e} 無法讀取 JPEG XL 檔案:{e} @msg Imported library {name} 已匯入資料庫 {name} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6ee1a6f5f..106071a12 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -105,7 +105,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | PSB | open | yes (merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; 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, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); 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) | | -| JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | +| JPEG XL `.jxl` | — | yes (first frame; 16-bit and floating-point samples as 8 bits, alpha kept; CMYK refused) | no | decoded with jxl-oxide; the size is checked from the header before decoding; container Exif resolution sizes opened and placed images; tests in `crates/engine/src/cmd/fileio/tests_jxlread.rs` and `crates/engine/tests/import_fuzz.rs` | | HEIC / HEIF | open, place | no | no | | | JPEG 2000 | open, place | no | no | | | PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` | From 67516d6365e44ce6189a86fd963310bca3b054b3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:17:13 -0700 Subject: [PATCH 18/36] File formats: JPEG XL swaps a rotated image's resolution and refuses unconverted gray profiles An orientation that exchanges the axes (5 to 8) now swaps the Exif horizontal and vertical resolution, so Open and Place size the oriented pixels correctly. A gray image whose embedded ICC profile the decoder can't convert to sRGB is refused instead of being stored as RGB without a profile. --- crates/engine/src/cmd/fileio/jxlread.rs | 31 +++++++++++++------ crates/engine/src/cmd/fileio/load.rs | 4 +-- crates/engine/src/cmd/fileio/tests_jxlread.rs | 28 +++++++++++++---- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 011af6a0f..9e0a0b64c 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -6,7 +6,8 @@ //! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC //! profiles that describe one of them. Any other image with an embedded ICC profile comes back //! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and with -//! the profile when it is an RGB one. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! the profile when it is an RGB one. Gray images with an ICC profile the decoder cannot convert +//! are refused. 16-bit and floating-point samples become 8 bits, alpha comes back straight, //! and CMYK images are refused. The file is untrusted: the decoder is given only a container's //! image boxes, the image's size is checked from its header before any pixels are decoded, and the //! decoder's image buffers are capped. @@ -39,13 +40,19 @@ pub fn is_jxl(bytes: &[u8]) -> bool { bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER) } +/// Resolution after the decoder applies the image header's orientation. +pub(crate) fn oriented_resolution(ppi: Option<(f64, f64)>, orientation: u32) -> Option<(f64, f64)> { + ppi.map(|(x, y)| if (5..=8).contains(&orientation) { (y, x) } else { (x, y) }) +} + /// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors -/// are in when they are not converted to sRGB ([`kept_profile`]). Fails, without decoding the +/// are in when they are not converted to sRGB ([`output_profile`]), and the header orientation. +/// Fails, without decoding the /// pixels, when the image would take more than `max_alloc` bytes as RGBA. The image is also too /// large to draw when the decoder's buffers for its pixels need more than they are allowed. A /// container whose selected boxes exceed `max_alloc` or [`HEADER_BUFFERS`], whichever is greater, /// is refused with "the JPEG XL container is too large to read". -pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), String> { +pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>, u32), String> { let pixel_boxes = pixel_boxes(bytes, max_alloc.max(HEADER_BUFFERS as u64))?; let bytes = pixel_boxes.as_deref().unwrap_or(bytes); // The decoder reads the header first, with buffers for the header alone. @@ -75,7 +82,8 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option< let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true); let intent = RenderingIntent::Relative; let srgb = if metadata.grayscale() { EnumColourEncoding::gray_srgb(intent) } else { EnumColourEncoding::srgb(intent) }; - let profile = kept_profile(metadata.xyb_encoded, image.original_icc()); + let profile = output_profile(metadata.xyb_encoded, metadata.grayscale(), image.original_icc())?; + let orientation = metadata.orientation; // Premultiplied colors are divided by alpha in the encoding they are stored in, and converted // to sRGB after that. XYB-encoded images come from the decoder in linear sRGB (tone mapped when // they are HDR), which the sRGB transfer curve alone turns into sRGB. @@ -115,7 +123,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option< } to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?; } - Ok((out, profile)) + Ok((out, profile, orientation)) } /// How colors whose alpha was divided out become sRGB. @@ -226,11 +234,14 @@ fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool { xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty())) } -/// The embedded ICC profile `icc` when the colors come back as stored in it -/// ([`to_srgb_without_cms`] is false), for the PNG they are kept in. Only an RGB profile is kept: -/// the colors of a gray image come back as RGB. -pub(crate) fn kept_profile(xyb: bool, icc: Option<&[u8]>) -> Option> { - icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !to_srgb_without_cms(xyb, Some(icc))).map(<[u8]>::to_vec) +/// The embedded RGB profile when the decoder leaves colors in it. Gray colors without a +/// conversion to sRGB cannot be stored as RGB without changing their profile. +pub(crate) fn output_profile(xyb: bool, grayscale: bool, icc: Option<&[u8]>) -> Result>, String> { + let converts = to_srgb_without_cms(xyb, icc); + if grayscale && !converts { + return Err(unreadable("unsupported grayscale ICC profile")); + } + Ok(icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !converts).map(<[u8]>::to_vec)) } /// The decoder's error `e` as a message. When the decoder's buffers needed more than they were diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 11a05cc78..f111076d2 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -636,8 +636,8 @@ pub fn raster_image(bytes: &[u8]) -> Result { return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, None, ppi); } if jxlread::is_jxl(bytes) { - let (img, icc) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; - return as_png(img, icc, ppi); + let (img, icc, orientation) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; + return as_png(img, icc, jxlread::oriented_resolution(ppi, orientation)); } let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; if reader.format().is_none() && super::tgaread::is_targa(bytes) { diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index 50255ae0a..1774cb3a2 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -6,7 +6,7 @@ use zune_core::bit_depth::BitDepth; use zune_core::colorspace::ColorSpace; use zune_core::options::EncoderOptions; -use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, pixel_boxes, to_rgba}; +use super::jxlread::{ToSrgb, decode, is_jxl, oriented_resolution, output_profile, pixel_boxes, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -115,6 +115,16 @@ fn container_exif_resolution_sizes_open_and_place() { assert_eq!(super::ppi::resolution(&s.doc().unwrap().doc.images.values().next().unwrap().bytes), Some((144.0, 72.0))); } +#[test] +fn jxl_axis_exchanging_orientations_swap_resolution() { + let ppi = Some((144.0, 72.0)); + for orientation in 1..=8 { + let expected = if orientation >= 5 { Some((72.0, 144.0)) } else { ppi }; + assert_eq!(oriented_resolution(ppi, orientation), expected, "orientation {orientation}"); + } + assert_eq!(oriented_resolution(None, 6), None); +} + /// A `.jxl` file copied in a file manager pastes as its image. #[test] fn a_copied_jxl_file_pastes_as_its_image() { @@ -273,12 +283,18 @@ fn colors_left_in_their_icc_profile_keep_it() { let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative); let rgb = unnamed(&srgb, b"rTRC"); assert_ne!(rgb, jxl_color::icc::colour_encoding_to_icc(&srgb), "a tag renamed"); - assert_eq!(kept_profile(false, Some(&rgb)), Some(rgb.clone())); - assert_eq!(kept_profile(true, Some(&rgb)), None, "XYB-encoded colors become sRGB"); - assert_eq!(kept_profile(false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))), None, "converted to sRGB"); + assert_eq!(output_profile(false, false, Some(&rgb)).unwrap(), Some(rgb.clone())); + assert_eq!(output_profile(true, false, Some(&rgb)).unwrap(), None, "XYB-encoded colors become sRGB"); + assert_eq!(output_profile(false, false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))).unwrap(), None, "converted to sRGB"); let gray = unnamed(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative), b"kTRC"); - assert_eq!(kept_profile(false, Some(&gray)), None, "gray"); - assert_eq!(kept_profile(false, None), None); + assert!(!jxl_oxide::ColorEncodingWithProfile::with_icc(&gray).unwrap().icc_profile().is_empty()); + assert_eq!(output_profile(false, true, Some(&gray)).unwrap_err(), "the JPEG XL file can't be read: unsupported grayscale ICC profile"); + assert_eq!( + output_profile(false, true, Some(&jxl_color::icc::colour_encoding_to_icc(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative)))) + .unwrap(), + None + ); + assert_eq!(output_profile(false, false, None).unwrap(), None); // The PNG carries the profile, the pixels and the resolution. let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap(); for icc in [Some(rgb), None] { From a4a3f8c13e88a07fb19d3e7904dcf18c512de259 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 18:51:34 -0700 Subject: [PATCH 19/36] Fix #3526: the bounding box follows a resize drag With View > Show Bounding Box on, a resize by a bounding-box handle keeps the box and its handles drawn at the bounds the drag gives. A move, a rotation and a corner-radius drag still hide the box until the release (#712). With Real-time Drawing off the document stays at the drag's snapshot, so the box is measured from the drag's pending preview, through the same cache as the deferred outlines. --- crates/engine/src/lib.rs | 7 ++- crates/engine/src/tooling.rs | 61 ++++++++++++++---- crates/tools/src/lib.rs | 7 ++- crates/tools/src/select.rs | 4 ++ crates/ui-egui/src/canvas/overlays.rs | 16 ++++- crates/ui-egui/src/canvas/tests.rs | 90 +++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d5b4bdb03..87502be6f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -208,9 +208,10 @@ pub struct Session { /// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]). /// Tests set it. pub search_rules: Option, - /// Canvas feedback cached for the pending deferred preview ([`Session::overlays`]); cleared - /// when the preview params change or the interaction ends. - pub(crate) deferred_overlay_cache: Option<(String, Value, Vec)>, + /// Canvas feedback cached for the pending deferred preview ([`Session::overlays`], + /// [`Session::deferred_transform_box`]); cleared when the preview params change or the + /// interaction ends. + pub(crate) deferred_overlay_cache: Option, /// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set /// it. pub search_threads: Option, diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c54bda129..df6b01cc3 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -572,6 +572,11 @@ impl Session { self.tool.transforming() } + /// Is the active tool resizing the selection by a bounding-box handle? + pub fn tool_resizing(&self) -> bool { + self.tool.resizing() + } + /// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)? pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool { self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) @@ -833,25 +838,48 @@ impl Session { Ok(ui) } - /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take - /// outlines from the objects it touched, then put the document back at the interaction snapshot. - fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { - if self.deferred_overlay_cache.as_ref().is_some_and(|(c, params, _)| c == cmd && params == p) { - return self.deferred_overlay_cache.as_ref().map(|(_, _, o)| o.clone()).unwrap_or_default(); + /// With Real-time Drawing off, the selection's transform box ([`Session::transform_box`]) as the + /// pending preview of a handle resize leaves it, measured once per preview step; `None` when no + /// such preview is pending, or when the preview fails. + pub fn deferred_transform_box(&mut self) -> Option { + if !self.tool.resizing() { + return None; } + let (cmd, p) = self.unapplied_interaction_preview()?; + self.deferred_feedback(&cmd, &p)?.transform_box + } + + fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { + self.deferred_feedback(cmd, p).map(|f| f.overlays.clone()).unwrap_or_default() + } + + /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take + /// outlines from the objects it touched (and, during a handle resize, their transform box), then + /// put the document back at the interaction snapshot. + fn deferred_feedback(&mut self, cmd: &str, p: &Value) -> Option<&DeferredFeedback> { + if !self.deferred_overlay_cache.as_ref().is_some_and(|f| f.cmd == cmd && f.params == *p) { + let feedback = self.measure_deferred_preview(cmd, p)?; + self.deferred_overlay_cache = Some(feedback); + } + self.deferred_overlay_cache.as_ref() + } + + fn measure_deferred_preview(&mut self, cmd: &str, p: &Value) -> Option { let (snapshot, selection, revision, paint, interaction_fields) = { - let Some(st) = self.active() else { return vec![] }; - let Some(it) = st.interaction.as_ref() else { return vec![] }; + let st = self.active()?; + let it = st.interaction.as_ref()?; if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) { - return vec![]; + return None; } let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone()); (st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields) }; - let overlays = if self.preview(cmd, p).is_ok() { - self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default() + let (overlays, transform_box) = if self.preview(cmd, p).is_ok() { + let overlays = self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default(); + let objects = self.active().map(|st| st.selection.objects.clone()).unwrap_or_default(); + (overlays, if self.tool.resizing() { self.transform_box(&objects) } else { None }) } else { - vec![] + (vec![], None) }; if let Ok(st) = self.doc_mut() { st.doc = snapshot; @@ -864,11 +892,18 @@ impl Session { } } self.pending_paint = paint; - self.deferred_overlay_cache = Some((cmd.to_string(), p.clone(), overlays.clone())); - overlays + Some(DeferredFeedback { cmd: cmd.to_string(), params: p.clone(), overlays, transform_box }) } } +/// What the canvas shows for a deferred preview: see [`Session::deferred_transform_box`]. +pub(crate) struct DeferredFeedback { + cmd: String, + params: Value, + overlays: Vec, + transform_box: Option, +} + fn outlines_for_selection(doc: &vectorcraft_doc::Document, selection: &Selection) -> Vec { selection .objects diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 67d2f5909..20b4178e1 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -139,10 +139,15 @@ pub trait Tool: Send { false } /// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box, - /// so only the art is seen going with the pointer)? + /// so only the art is seen going with the pointer, except during a handle resize)? fn transforming(&self) -> bool { false } + /// Is the transforming drag a resize by a bounding-box handle? The canvas then keeps drawing + /// the bounding box at the changing bounds. + fn resizing(&self) -> bool { + false + } /// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool /// with a stop selected takes Delete and the arrows)? fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index bcbce02ee..0aac591c9 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -665,6 +665,10 @@ impl Tool for SelectionTool { self.alt_hover } + fn resizing(&self) -> bool { + matches!(self.state, State::Scaling { .. }) + } + fn transforming(&self) -> bool { match &self.state { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 4f7e83258..b01deee0f 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -91,12 +91,22 @@ fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) { // The Selection tool's bounding box (rotated with the objects after a rotation). It hides while - // a drag moves or resizes the selection, so only the art is seen going with the pointer (#712). + // a drag moves or rotates the selection, so only the art is seen going with the pointer (#712). + // A handle resize keeps it at the changing bounds (#3526). + let resizing = app.session.tool_resizing(); let show_box = app.session.tool_id() == "selection" - && !app.session.tool_transforming() + && (resizing || !app.session.tool_transforming()) && app.ui.view.bounding_box && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); - let bbox = if show_box { app.selection_box() } else { None }; + // With Real-time Drawing off the document stays at the drag's snapshot, so the box is measured + // from the pending preview; otherwise each step's revision measures it once (`selection_box`). + let bbox = if !show_box { + None + } else if let Some(b) = resizing.then(|| app.session.deferred_transform_box()).flatten() { + Some(b) + } else { + app.selection_box() + }; let st = app.session.active(); let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id)))); let big_bounds = if big { app.selection_bounds() } else { None }; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4b59bdb8f..24cf3a6b1 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3638,6 +3638,96 @@ fn hidden_corner_widgets_do_not_answer_the_pointer() { assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app)); } +/// One headless canvas frame → the bounding box drawn: its outline's corners on screen (the +/// closed four-point line in the layer color; `None` when it is hidden) and the number of its +/// handles (white 6 px squares). +fn bounding_box_drawn(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Option>, usize) { + let st = app.session.active().unwrap(); + let layer = egui::epaint::ColorMode::Solid(c32(st.doc.layer_color(st.selection.objects[0]))); + let drawn = shapes(app, ctx); + let outline = drawn.iter().find_map(|s| match s { + Shape::Path(ps) if ps.closed && ps.points.len() == 4 && ps.stroke.color == layer => Some(ps.points.clone()), + _ => None, + }); + let handle = |s: &&Shape| matches!(s, Shape::Rect(r) if r.rect.size() == vec2(6.0, 6.0) && r.fill == Color32::WHITE); + (outline, drawn.iter().filter(handle).count()) +} + +/// The selection's bounding box corners on screen, as measured from the document. +fn box_corners(app: &mut VectorcraftApp) -> Vec { + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + app.selection_box().unwrap().corners().iter().map(|q| xf.to_screen(*q)).collect() +} + +/// Do two outlines have the same corners, to 0.01 px? +fn same_corners(a: &[Pos2], b: &[Pos2]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| p.distance(*q) < 0.01) +} + +/// #3526: a resize by a bounding-box handle keeps the bounding box and its handles drawn at the +/// bounds the drag gives, with Real-time Drawing on and off (off, the document stays at the drag's +/// snapshot until the release). +#[test] +fn a_resize_drag_keeps_the_bounding_box_at_the_new_bounds() { + for real_time in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.session.execute("prefs.set", &json!({"key": "realTimeDrawing", "value": real_time})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let before = box_corners(&mut app); + let (outline, handles) = bounding_box_drawn(&mut app, &ctx); + assert!(outline.is_some_and(|o| same_corners(&o, &before)) && handles == 8, "real time {real_time}: the box at rest"); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 160.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 263.0, 211.0), view).unwrap(); + assert!(app.session.tool_resizing(), "real time {real_time}: the handle was hit"); + let (during, handles) = bounding_box_drawn(&mut app, &ctx); + assert_eq!(handles, 8, "real time {real_time}: the handles show during the resize"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 263.0, 211.0), view).unwrap(); + let after = box_corners(&mut app); + assert!(!same_corners(&after, &before), "real time {real_time}: the drag resized the ellipse"); + assert!(during.as_ref().is_some_and(|o| same_corners(o, &after)), "real time {real_time}: {during:?}, resized to {after:?}"); + let (outline, handles) = bounding_box_drawn(&mut app, &ctx); + assert!(outline.is_some_and(|o| same_corners(&o, &after)) && handles == 8, "real time {real_time}: the box after the release"); + } +} + +/// A move, a rotation and a corner-radius drag keep hiding the bounding box and its handles until +/// the release (#712, #1229). +#[test] +fn a_move_rotate_or_corner_radius_drag_hides_the_bounding_box() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("selection"); + let drag = |app: &mut VectorcraftApp, from: Point, to: Point| { + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), view).unwrap(); + assert!(app.session.tool_transforming() && !app.session.tool_resizing()); + let during = bounding_box_drawn(app, &ctx); + app.session.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), view).unwrap(); + during + }; + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8); + assert_eq!(drag(&mut app, Point::new(150.0, 150.0), Point::new(160.0, 160.0)), (None, 0), "a move"); + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the move"); + let offset = 10.0 / app.view_info().zoom; + assert_eq!(drag(&mut app, Point::new(210.0 + offset, 210.0 + offset), Point::new(260.0, 160.0)), (None, 0), "a rotation"); + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the rotation"); + let corner = { + let st = app.session.active().unwrap(); + vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap() + }; + assert_eq!(drag(&mut app, corner, corner + vectorcraft_geom::Vec2::new(10.0, 10.0)), (None, 0), "a corner-radius drag"); + assert_eq!(bounding_box_drawn(&mut app, &ctx).1, 8, "back after the corner-radius drag"); +} + /// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection /// tool then answers neither: a double-click on the widget's place leaves point type as it is, /// and a drag from a corner handle's place does not scale. With edges shown, both answer. From a1c908fbd20f5ec11f083d7649678c1d3078becf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 00:06:04 -0700 Subject: [PATCH 20/36] Linux: test VECTORCRAFT_TITLEBAR read from the environment with System Title Bar off and on Runs the test binary again with VECTORCRAFT_TITLEBAR set to app, system, desktop and an unknown value, and checks titlebar_override, custom_titlebar with no saved preferences and with System Title Bar off and on, and the window decorations native_options sets before the window opens. On macOS and Windows the variable is not read. --- apps/vectorcraft/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index aead748a9..1cc5ba1e9 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -171,6 +171,45 @@ fn every_linux_desktop_gets_the_app_title_bar_unless_system_title_bar_is_on() { } } +/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on. +/// The variable is process-wide and read once, so each value runs in a separate test process. +#[test] +fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { + const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD"; + if std::env::var_os(CHILD).is_none() { + for value in ["app", "system", "desktop", "unknown"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"]) + .env(CHILD, "1") + .env("VECTORCRAFT_TITLEBAR", value) + .output() + .unwrap(); + assert!( + output.status.success(), + "VECTORCRAFT_TITLEBAR={value}\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + } + let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap(); + let expected = match value.as_str() { + _ if cfg!(any(target_os = "macos", target_os = "windows")) => None, + "app" => Some(true), + "system" | "desktop" => Some(false), + _ => None, + }; + assert_eq!(super::titlebar_override(), expected, "VECTORCRAFT_TITLEBAR={value}"); + let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); + let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); + for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] { + let custom = super::custom_titlebar(saved); + assert_eq!(custom, super::custom_titlebar_from(expected, opted_into_system), "{value}, preference {opted_into_system}"); + assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}"); + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] From 5e1a50c75ae5ffe93187ce643248a9013a16ab88 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 01:52:37 -0700 Subject: [PATCH 21/36] Linux: hold spawning while the title bar test starts its child process On Linux and the BSDs, the_titlebar_variable_decides_the_frame_before_the_window_opens now holds spawning() while it starts each child test process, as the portal save test does around dbus-daemon, so the child does not keep a copy of a GPU start test's locked start file. --- apps/vectorcraft/src/tests.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 1cc5ba1e9..e6ebaedcf 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -178,12 +178,21 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD"; if std::env::var_os(CHILD).is_none() { for value in ["app", "system", "desktop", "unknown"] { - let output = std::process::Command::new(std::env::current_exe().unwrap()) + // Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a + // copy of a GPU start test's locked start file (see `CHILD_SPAWN`). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + let spawning = super::spawning(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"]) .env(CHILD, "1") .env("VECTORCRAFT_TITLEBAR", value) - .output() - .unwrap(); + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + drop(spawning); + let output = child.unwrap().wait_with_output().unwrap(); assert!( output.status.success(), "VECTORCRAFT_TITLEBAR={value}\n{}\n{}", From 3698a76c5df989756776369ccdb62d027c61e4cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:09:11 -0800 Subject: [PATCH 22/36] File formats: JPEG XL refuses colors left in a profile that is not an RGB one A color JPEG XL image whose embedded ICC profile the decoder cannot convert to sRGB kept its samples as stored and lost the profile when that profile was not an RGB one, so the image drew those samples as sRGB. It is now refused as unreadable, as a gray image in such a profile already is. --- crates/engine/src/cmd/fileio/jxlread.rs | 16 ++++++++++------ crates/engine/src/cmd/fileio/tests_jxlread.rs | 5 +++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 9e0a0b64c..1d4e3d138 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -4,10 +4,10 @@ //! //! Colors come back as sRGB whenever the decoder converts them without a color management //! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC -//! profiles that describe one of them. Any other image with an embedded ICC profile comes back -//! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and with -//! the profile when it is an RGB one. Gray images with an ICC profile the decoder cannot convert -//! are refused. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! profiles that describe one of them. Any other image with an embedded RGB ICC profile comes +//! back with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and +//! with the profile. Gray images, and images whose profile is not an RGB one, are refused when the +//! decoder cannot convert their ICC profile. 16-bit and floating-point samples become 8 bits, alpha comes back straight, //! and CMYK images are refused. The file is untrusted: the decoder is given only a container's //! image boxes, the image's size is checked from its header before any pixels are decoded, and the //! decoder's image buffers are capped. @@ -235,13 +235,17 @@ fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool { } /// The embedded RGB profile when the decoder leaves colors in it. Gray colors without a -/// conversion to sRGB cannot be stored as RGB without changing their profile. +/// conversion to sRGB cannot be stored as RGB without changing their profile, and neither can +/// colors left in a profile that is not an RGB one. pub(crate) fn output_profile(xyb: bool, grayscale: bool, icc: Option<&[u8]>) -> Result>, String> { let converts = to_srgb_without_cms(xyb, icc); if grayscale && !converts { return Err(unreadable("unsupported grayscale ICC profile")); } - Ok(icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !converts).map(<[u8]>::to_vec)) + if !converts && icc.and_then(|icc| icc.get(16..20)) != Some(b"RGB ") { + return Err(unreadable("unsupported ICC profile color space")); + } + Ok(icc.filter(|_| !converts).map(<[u8]>::to_vec)) } /// The decoder's error `e` as a message. When the decoder's buffers needed more than they were diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index 1774cb3a2..6e6af80c8 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -295,6 +295,11 @@ fn colors_left_in_their_icc_profile_keep_it() { None ); assert_eq!(output_profile(false, false, None).unwrap(), None); + // A color image left in a profile that is not an RGB one can't be stored as RGB. + let mut lab = rgb.clone(); + lab[16..20].copy_from_slice(b"Lab "); + assert_eq!(output_profile(false, false, Some(&lab)).unwrap_err(), "the JPEG XL file can't be read: unsupported ICC profile color space"); + assert_eq!(output_profile(true, false, Some(&lab)).unwrap(), None, "XYB-encoded colors become sRGB"); // The PNG carries the profile, the pixels and the resolution. let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap(); for icc in [Some(rgb), None] { From b109378ae6861f7ce70391ea12014185ca6a1c7d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 23:22:11 -0800 Subject: [PATCH 23/36] Plug-ins: live-effect plug-ins at API level 2 can paint the pieces they return A live effect whose manifest has "api": 2 and "paintOutput": true returns {"objects": [...]} as pieces, each drawn by itself in order with fills and strokes of its own in the forms a filter returns. A piece without fills or strokes keeps the ones the effect was given, and an empty list paints none. An effect on a fill returns only fills, one on a stroke only strokes. The whole result is checked before any of it is drawn: the output limits, finite numbers in range with nothing clamped, known keys only, and every swatch and pattern it names in the document. One fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in plugin.info's lastError. Each piece counts against the geometry-effect budget, so the painted effects of one stack make at most 100,000 pieces. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. The plug-in API level rises to 2; plugin.list and plugin.info give api and paintOutput. Effects without the key behave as before. --- crates/effects/src/group.rs | 2 +- crates/effects/src/reshape.rs | 213 ++++++++++++++++-- crates/engine/src/cmd/plugin.rs | 8 +- crates/engine/src/tests/plugins.rs | 160 ++++++++++++++ crates/engine/tests/plugin_fuzz.rs | 26 +++ crates/plugins/src/effect.rs | 128 +++++++++-- crates/plugins/src/lib.rs | 4 +- crates/plugins/src/manifest.rs | 51 ++++- crates/plugins/src/objects.rs | 10 +- crates/plugins/src/painted.rs | 323 ++++++++++++++++++++++++++++ crates/plugins/src/painted/tests.rs | 149 +++++++++++++ crates/plugins/src/permissions.rs | 4 +- crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/ca.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/hi.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/ka.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/tr.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/mcp.md | 7 +- docs/plugins.md | 68 +++++- docs/target-app-parity.md | 1 + 38 files changed, 1111 insertions(+), 66 deletions(-) create mode 100644 crates/plugins/src/painted.rs create mode 100644 crates/plugins/src/painted/tests.rs diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index ce8c38077..aa222809d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -711,7 +711,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - crate::reshape::reshape_leaves(&mut m, &fx); + crate::reshape::reshape_leaves_in(&mut m, &fx, doc); } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index b3acf07a5..a84d37bd0 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -102,10 +102,27 @@ fn reaches_appearance(e: &Effect) -> bool { } /// Is `n` a path or compound path that its Transform effects make into art of separate objects -/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through -/// that art ([`reshape`]). +/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose +/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and +/// expanded through that art ([`reshape`]). pub fn has_transform_art(n: &Node) -> bool { - has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) + has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) +} + +/// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id, +/// else `None`. +fn painted_plugin(e: &Effect) -> Option<&str> { + let id = vectorcraft_plugins::effect::plugin_id(&e.id).filter(|_| e.visible)?; + vectorcraft_plugins::effect::paints_output(id).then_some(id) +} + +/// Is `n` a path or compound path with a live-effect plug-in that paints what it returns +/// ([`painted_plugin`]), its own or on a fill or stroke that paints? Each piece the plug-in returns +/// is then an object of its own with its own paints ([`paint_leaf`], [`transform_item_leaf`]). +pub fn has_painted_effects(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) + && (n.appearance.effects.iter().any(|e| painted_plugin(e).is_some()) + || n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(|e| painted_plugin(e).is_some()))) } /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a @@ -527,11 +544,11 @@ fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outl } _ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?, }; - reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart); + reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, doc); if apart { hoist_copies(&mut art); } - transform_items(&mut art); + transform_items(&mut art, doc); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { project_leaves(&mut art, &h); @@ -594,9 +611,15 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { reshape_leaves_within(art, fx, Budget::EFFECTS); } +/// [`reshape_leaves`], with the swatches and patterns that painted live-effect plug-ins name looked +/// up in `doc` ([`reshape_leaves_apart`]). +pub(crate) fn reshape_leaves_in(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { + reshape_leaves_apart(art, fx, Budget::EFFECTS, false, doc); +} + /// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { - reshape_leaves_apart(art, fx, budget, false); + reshape_leaves_apart(art, fx, budget, false, None); } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -616,8 +639,10 @@ fn hoist_copies(art: &mut Node) { } /// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects -/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. -fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) { +/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect +/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]), +/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) { if fx.is_empty() { return; } @@ -647,6 +672,17 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + if let Some(plugin) = painted_plugin(e) { + for_each_leaf(art, &mut |leaf| { + let Some((path, _)) = leaf_geometry(leaf) else { return }; + let before = Budget::of(&path); + let room = max.minus(used.minus(before)); + if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc) { + used = used.minus(before).plus(made); + } + }); + continue; + } let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); @@ -772,15 +808,111 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling made } +/// Live-effect plug-in `plugin` (one that paints what it returns) with `params` run on `leaf`, a +/// path or compound path, as one of its object's effects with reference box `bounds`: `leaf` +/// becomes a group with its id and name of one object for each piece returned, in order, each with +/// the piece's geometry and the leaf's appearance with the piece's fills and strokes in place of +/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces +/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost +/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are +/// refused, or they would pass `budget`. +fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option { + let (path, rule) = leaf_geometry(leaf)?; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None }; + let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; + let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); + if !budget.fits(made) { + return None; + } + let children = pieces + .iter() + .map(|p| { + let mut one = leaf.clone(); + one.name = None; + one.appearance = vectorcraft_plugins::painted::appearance(&leaf.appearance, p).into(); + set_geometry(&mut one, p.path.clone(), p.rule); + Arc::new(one) + }) + .collect(); + let mut g = Node::group(leaf.id, children); + g.name = leaf.name.clone(); + *leaf = g; + Some(made) +} + +/// What a painted piece with geometry `path` costs: its anchors and lines, plus a share of the +/// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most +/// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths. +fn piece_cost(path: &PathData) -> Budget { + let max = vectorcraft_plugins::objects::MAX_OBJECTS; + Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max }) +} + +/// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of +/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference +/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that +/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item` with those +/// paints ([`vectorcraft_plugins::painted::items`]); one that gives none keeps the leaf's. Returns +/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, +/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass +/// `budget`. +fn paint_piece( + leaf: &mut Node, + item: &AppearanceItem, + plugin: &str, + params: &serde_json::Value, + bounds: Rect, + budget: Budget, + doc: Option<&Document>, +) -> Option { + let (path, rule) = leaf_geometry(leaf)?; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) }; + let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; + let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); + if !budget.fits(made) { + return None; + } + let mut children = vec![]; + for p in pieces.iter() { + let gives = match item { + AppearanceItem::Fill(_) => p.fills.is_some(), + AppearanceItem::Stroke(_) => p.strokes.is_some(), + }; + let items = if gives { vectorcraft_plugins::painted::items(item, p) } else { leaf.appearance.items.clone() }; + if items.is_empty() { + continue; + } + let mut one = leaf.clone(); + one.appearance.items = items; + set_geometry(&mut one, p.path.clone(), p.rule); + children.push(Arc::new(one)); + } + *leaf = Node::group(leaf.id, children); + Some(made) +} + /// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose /// own Transform effect has Transform Patterns or Scale Strokes & Effects on -/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]). -/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is -/// more). -fn transform_items(art: &mut Node) { +/// ([`item_transform_options`]), or whose own live-effect plug-in paints what it returns +/// ([`painted_plugin`]), made into a group of objects of its own ([`transform_item_leaf`]). The +/// new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is more). +/// The swatches and patterns painted pieces name must be in `doc`; without one they may name none. +fn transform_items(art: &mut Node, doc: Option<&Document>) { let mut used = art_cost(art); let max = Budget::EFFECTS.at_least(used); - for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used)); + for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used, doc)); +} + +/// Is `e` an effect that makes a fill's or stroke's piece of a path an object of its own: a +/// Transform effect that [`reaches_appearance`], or a live-effect plug-in that paints what it +/// returns ([`painted_plugin`])? +fn makes_piece(e: &Effect) -> bool { + reaches_appearance(e) || painted_plugin(e).is_some() +} + +/// Does `item` paint and carry an effect that [`makes_piece`]? +fn item_makes_piece(item: &AppearanceItem) -> bool { + crate::paints(item) && item.effects().iter().any(makes_piece) } /// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform @@ -795,8 +927,17 @@ fn transform_items(art: &mut Node) { /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` /// what it has spent. -fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { - if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) { +/// +/// A fill or stroke whose first such effect is a live-effect plug-in that paints what it returns +/// ([`painted_plugin`]) becomes, in place of a transformed path, one path for each piece the +/// plug-in returns, painted by copies of the fill or stroke with the piece's paints +/// ([`vectorcraft_plugins::painted::items`]); the plug-in is given the geometry before it and told +/// which fill or stroke it is on. When it fails (or names a swatch or pattern not in `doc`, or any +/// without a document), the +/// fill or stroke paints that geometry as it is. The geometry effects after it then apply to all +/// the pieces as one ([`transform_piece`]). +fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Option<&Document>) { + if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_makes_piece) { return; } let Some((path, rule)) = leaf_geometry(leaf) else { return }; @@ -824,12 +965,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { let mut pieces: Vec> = vec![]; let mut plain: Vec = vec![]; // Which items become a transformed piece of their own; every other run of items is one plain piece. - let transformed: Vec = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect(); + let transformed: Vec = original.appearance.items.iter().map(item_makes_piece).collect(); // The number of pieces after item `i` in paint order: each costs at least the leaf's geometry. let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count(); for (i, item) in original.appearance.items.iter().enumerate() { let fx = item.effects(); - let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else { + let Some(at) = fx.iter().position(makes_piece).filter(|_| crate::paints(item)) else { plain.push(item.clone()); continue; }; @@ -850,6 +991,28 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { }; let Some(tb) = g.bounds() else { continue }; let params = merged_params(&fx[at].id, &fx[at].params); + if let Some(plugin) = painted_plugin(&fx[at]) { + let mut it = item.clone(); + let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) }; + let painted = vectorcraft_plugins::effect::apply_painted(plugin, ¶ms, &g, tb, rule, paints, doc) + .map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::>()) + .filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus))); + // A failed run draws the fill or stroke on the geometry it was given. + let returned = painted.unwrap_or_else(|| vec![(g.clone(), rule, vec![it])]); + let mut group: Vec> = vec![]; + for (path, rule, items) in returned.into_iter().filter(|(_, _, items)| !items.is_empty()) { + *used = used.plus(piece_cost(&path)); + let mut p = piece(items); + set_geometry(&mut p, path, rule); + group.push(Arc::new(p)); + } + let mut p = Node::group(leaf.id, group); + transform_piece(&mut p, ctx.seed, limit, used, doc); + pieces.push(Arc::new(p)); + continue; + } let (_, copies) = crate::distort::transform_map(tb, ¶ms, ctx.seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); @@ -860,7 +1023,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { set_geometry(&mut p, g.clone(), rule); let made = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); - transform_piece(&mut p, ctx.seed, limit, used); + transform_piece(&mut p, ctx.seed, limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -879,9 +1042,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { /// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of /// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with /// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. -/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep -/// to `max` together with the rest of the art (`used`). -fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { +/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path +/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and +/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art +/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. +fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { @@ -897,6 +1062,12 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { // What the other paths leave of the budget. let before = Budget::of(&path); let room = max.minus(used.minus(before)); + if let Some(plugin) = painted_plugin(&e) { + if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) { + *used = used.minus(before).plus(made); + } + return; + } if reaches_appearance(&e) { let params = merged_params(&e.id, &e.params); let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 565eded7f..68a0bfde2 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -58,6 +58,10 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + v["api"] = json!(m.api); + if m.paint_output { + v["paintOutput"] = json!(true); + } v } @@ -493,7 +497,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), api (the plug-in API level its manifest is written for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -502,7 +506,7 @@ pub fn specs() -> Vec { "Plug-in Info", [], None, - "{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}", + "{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null; for a live effect with paintOutput, its name and the refused field, such as objects[1].strokes[0].width)}", always, info ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 36694ef87..7c96a458b 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -269,6 +269,166 @@ fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() { assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills"); } +/// The manifest of a test live effect `id` that declares `paintOutput` (at `api` 2), or that +/// doesn't (`paints` off). +fn painting(id: &str, paints: bool) -> String { + let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" }; + format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#) +} + +/// A 20 × 20 square at `x`, 40. +fn square_at(x: f64) -> PathData { + PathData::single(SubPath::polyline(&[Point::new(x, 40.0), Point::new(x + 20.0, 40.0), Point::new(x + 20.0, 60.0), Point::new(x, 60.0)], true)) +} + +fn solid(hex: &str) -> Value { + serde_json::to_value(Paint::solid(Color::from_hex(hex).unwrap())).unwrap() +} + +/// The 200 × 200 top left of the document, drawn on white. +fn drawn(s: &Session) -> vectorcraft_render::Rendered { + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts) +} + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// A live effect that declares `paintOutput` draws each piece it returns by itself, with the paints +/// it gives (a piece without `fills` keeps the object's), in place of the object; the same output +/// from an effect that doesn't declare it is one path painted by the object's fill. Drawing and +/// export leave the document alone, and Expand Appearance writes the pieces as ordinary paths in +/// one undo step. +#[test] +fn a_painted_live_effect_draws_its_pieces_with_their_own_paints() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.paints", true), &out)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.geometry", false), &out)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geometry"})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (RED, RED, WHITE), "without paintOutput the paint is ignored"); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.paints"})); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, RED, WHITE), "each piece with its own paint"); + // An upstream paint edit reaches the piece that keeps the object's fill; Undo brings it back. + run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": "#0000ff"})); + assert_eq!((drawn(&s).pixel(50, 50), drawn(&s).pixel(90, 50)), (GREEN, BLUE)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(drawn(&s).pixel(90, 50), RED); + run(&mut s, "edit.redo", json!({})); + assert_eq!(drawn(&s).pixel(90, 50), BLUE); + run(&mut s, "edit.undo", json!({})); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(svg.contains("#00ff00") && svg.contains("#ff0000"), "{svg}"); + assert_eq!(s.doc().unwrap().doc, before, "drawing and export don't change the document"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + // Expanded: two ordinary paths, green and red, and no live effect, as one undo step. + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "effect.expandAppearance", json!({})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let mut paths = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let Some(p) = n.path_data() { + assert!(n.appearance.effects.is_empty(), "no live effect left"); + paths.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone()))); + } + }); + let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap())); + assert_eq!(paths, [(square_at(40.0).bounds(), fill("#00ff00")), (square_at(80.0).bounds(), fill("#ff0000"))]); + assert_eq!(drawn(&s).pixel(50, 50), GREEN); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, id).appearance.effects[0].id, "plugin.test.engine.paints"); +} + +/// On a fill, a painted effect returns only fills: they replace that fill on each piece. A result +/// that breaks a rule (strokes from an effect on a fill, a swatch the document doesn't have) is +/// refused whole: the object is drawn as if the effect weren't there, and `plugin.info` names the +/// plug-in and the field. +#[test] +fn a_painted_effect_on_a_fill_and_refused_results() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let fills = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string(); + let strokes = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#00ff00"), "width": 2}]}]}).to_string(); + let mut gone = solid("#00ff00"); + gone["swatch"] = json!("Not a swatch"); + let unknown = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.fillpaint", true), &fills)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.strokepaint", true), &strokes)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.swatchpaint", true), &unknown)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.fillpaint", "item": fill})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the fill's piece, green"); + assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.fillpaint"}))["lastError"], Value::Null); + // A second painted effect on the same fill paints what it returns too. + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.bluepaint", true), &blue)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.bluepaint", "item": fill})); + let img = drawn(&s); + assert_eq!((img.pixel(90, 50), img.pixel(50, 50), img.pixel(15, 15)), (BLUE, WHITE, WHITE), "the second effect's piece, blue"); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 1, "item": fill})); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": fill})); + for (effect, item, field) in + [("test.engine.strokepaint", json!(fill), "objects[0].strokes"), ("test.engine.swatchpaint", Value::Null, "objects[0].fills[0].swatch")] + { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{effect}"), "item": item})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (WHITE, RED), "{effect}: drawn as it is"); + let e = run(&mut s, "plugin.info", json!({"id": effect}))["lastError"].as_str().unwrap_or_default().to_string(); + assert!(e.starts_with(&format!("Test {effect}: ")) && e.contains(field), "{effect}: {e}"); + let n = node(&s, id); + let kept = if item.is_null() { n.appearance.effects.clone() } else { n.appearance.items[fill].effects().to_vec() }; + assert_eq!(kept.iter().map(|e| e.id.as_str()).collect::>(), [format!("plugin.{effect}")], "the effect record stays"); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": item})); + } +} + +/// A painted effect on a group may link its pieces to the document's swatches. +#[test] +fn a_painted_group_effect_links_to_the_document_s_swatches() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true})); + run(&mut s, "select.all", json!({})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + let mut leaf = solid("#00ff00"); + leaf["swatch"] = json!("Leaf"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.grouppaint", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.grouppaint"})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green"); +} + +/// Every painted piece counts against the effects' budget, however small its path: two painted +/// effects in a stack that each return 350 tiny triangles make no more than 100,000 objects, not +/// 350 × 350 of them. +#[test] +fn painted_pieces_keep_to_the_effects_budget() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let out = json!({"objects": vec![json!({"path": tiny}); 350]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.many", true), &out)); + for _ in 0..2 { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.many"})); + } + let st = s.doc().unwrap(); + let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); + let mut paths = 0; + art.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + assert!((350..=100_000).contains(&paths), "{paths} paths"); +} + /// `CRAFT_PLUGIN_PAINTS_WASM=` saves a test live effect to try in the app /// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its /// object's stroke is 5 pt wide, and hands the geometry back otherwise. diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1f29627e0..652de8c19 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -242,6 +242,32 @@ proptest! { renders(&s, &out)?; } + /// A live effect that declares `paintOutput` returning hostile pieces, on objects or on a fill: + /// drawing, export and Expand Appearance never crash. + #[test] + fn hostile_painted_effect_output_never_crashes( + objects in prop::collection::vec(arb_object(), 0..5), + junk in prop::option::weighted(0.1, arb_junk()), + on_fill in any::(), + ) { + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + let out = output(&objects, &[], junk); + let manifest = r#"{"id":"fuzz.painted","name":"Fuzz","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#; + install(&mut s, &wat::parse_str(tpl::returning(manifest, &out)).unwrap()).unwrap(); + let item = if on_fill { json!(0) } else { Value::Null }; + let r = catch_quiet(|| s.execute("effect.apply", &json!({"effect": "plugin.fuzz.painted", "item": item})).map(|_| ())); + if let Err(msg) = r { + return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))); + } + renders(&s, &out)?; + let r = catch_quiet(|| s.execute("effect.expandAppearance", &json!({})).map(|_| ())); + if let Err(msg) = r { + return Err(TestCaseError::fail(format!("expanding panicked on {out}: {msg}"))); + } + renders(&s, &out)?; + } + #[test] fn mutated_and_garbage_modules_never_crash( flips in prop::collection::vec((any::(), any::()), 0..8), diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index b49f5b831..0246f609a 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -5,20 +5,25 @@ //! sees them ([`Paints`]): an effect can draw as wide as the stroke it sits on, or tell its fills //! apart, without asking for them again as parameters. //! +//! An effect whose manifest declares `"paintOutput": true` returns painted pieces instead +//! ([`apply_painted`], [`crate::painted`]): each is drawn by itself with paints of its own. +//! //! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by //! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when //! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and -//! reported by [`last_error`]. +//! reported by [`last_error`]. The swatches and patterns a painted result names are checked +//! against the document each time it is used. use std::collections::HashMap; use std::hash::{Hash, Hasher}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; -use vectorcraft_doc::{Appearance, AppearanceItem}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document}; use vectorcraft_geom::{FillRule, PathData, Rect}; use crate::objects::{decode, output_geometry, paint_lists}; +use crate::painted::{self, Piece, Scope}; use crate::{Error, Kind, Limits, Result, registry}; /// The effect id prefix of live-effect plug-ins: `plugin.`. @@ -82,6 +87,15 @@ impl Paints<'_> { }; Given { fills, strokes, item } } + + /// Where the effect sits: what the pieces of an effect that declares `paintOutput` may paint. + fn scope(&self) -> Scope { + match self.appearance.zip(self.item).and_then(|(ap, i)| ap.items.get(i)) { + Some(AppearanceItem::Fill(_)) => Scope::Fill, + Some(AppearanceItem::Stroke(_)) => Scope::Stroke, + None => Scope::Object, + } + } } #[derive(PartialEq)] @@ -111,9 +125,27 @@ impl Key { } } +/// What one run of a live effect gave: geometry, or the painted pieces of an effect that declares +/// `paintOutput` ([`crate::painted`]). +#[derive(Clone, Debug, PartialEq)] +enum Out { + Path(PathData), + Pieces(Arc>), +} + +impl Out { + /// The geometry: the path, or every piece's path in order. + fn geometry(&self) -> PathData { + match self { + Out::Path(p) => p.clone(), + Out::Pieces(pieces) => PathData::new(pieces.iter().flat_map(|p| p.path.subpaths.iter().cloned()).collect()), + } + } +} + #[derive(Default)] struct Cache { - entries: HashMap)>, + entries: HashMap)>, /// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]). anchors: usize, } @@ -125,10 +157,16 @@ fn anchors(p: &PathData) -> usize { p.subpaths.iter().map(|sp| sp.anchors.len() + 1).sum() } -/// Cache units an entry takes: its input and result paths, and the serialized parameters and -/// paints its key owns (one unit per [`CACHE_ANCHORS`]'s 50 bytes an anchor stands for). -fn weight(key: &Key, out: Option<&PathData>) -> usize { - anchors(&key.path) + out.map_or(0, anchors) + (key.params.len() + key.paints.len()) / 50 + 1 +/// Cache units an entry takes: its input and result paths, the serialized parameters and paints its +/// key owns (one unit per [`CACHE_ANCHORS`]'s 50 bytes an anchor stands for), and one for each +/// paint, gradient stop and freeform point a painted result holds. +fn weight(key: &Key, out: Option<&Out>) -> usize { + let out = match out { + None => 0, + Some(Out::Path(p)) => anchors(p), + Some(Out::Pieces(pieces)) => pieces.iter().map(|p| anchors(&p.path) + 1).sum::() + painted::paint_count(pieces), + }; + anchors(&key.path) + out + (key.params.len() + key.paints.len()) / 50 + 1 } static ERRORS: Mutex>> = Mutex::new(None); @@ -137,19 +175,60 @@ pub fn last_error(id: &str) -> Option { ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned() } -fn note(id: &str, r: &Result) { +/// Records how a run of plug-in `id` went: a failure as its last error (a painted effect's named +/// after the plug-in, [`painted::named`]), a success clearing it. +fn note(id: &str, plugin: &crate::Plugin, r: std::result::Result<(), &Error>) { let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); let errors = errors.get_or_insert_with(HashMap::new); match r { - Ok(_) => errors.remove(id), + Ok(()) => errors.remove(id), + Err(e) if plugin.manifest().paint_output => errors.insert(id.to_string(), painted::named(&plugin.manifest().name, e)), Err(e) => errors.insert(id.to_string(), e.to_string()), }; } +/// Does live-effect plug-in `id` return painted pieces (its manifest declares `paintOutput`)? +pub fn paints_output(id: &str) -> bool { + registry::get(id).is_some_and(|p| p.manifest().kind == Kind::Effect && p.manifest().paint_output) +} + /// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`), of an object painted /// with `paints`: the new geometry, or `None` when the plug-in isn't installed, isn't an effect, or -/// fails (the caller then keeps the geometry). +/// fails (the caller then keeps the geometry). For an effect that returns painted pieces, the +/// geometry is every piece's path in order. pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option { + evaluate(id, params, path, bounds, rule, paints).map(|(_, out)| out.geometry()) +} + +/// Runs live-effect plug-in `id`, which declares `paintOutput`, as [`apply`] does: its painted +/// pieces, each to draw by itself in order with the paints it gives ([`painted::appearance`], +/// [`painted::items`]). Every swatch and pattern the pieces name must be in `doc`; without a +/// document, pieces that name one are refused ([`painted::check_references`]). `None` when the +/// plug-in isn't installed, doesn't declare `paintOutput`, or fails; the caller then draws its input +/// unchanged, and [`last_error`] says why (a refusal for want of a document is not recorded). +pub fn apply_painted( + id: &str, + params: &Value, + path: &PathData, + bounds: Rect, + rule: FillRule, + paints: Paints, + doc: Option<&Document>, +) -> Option>> { + let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?; + let Out::Pieces(pieces) = out else { return None }; + // The document's swatches and patterns can change while the result stays cached. + let checked = painted::check_references(&pieces, doc); + if doc.is_some() { + note(id, &plugin, checked.as_ref().map(|_| ())); + } + checked.ok()?; + Some(pieces) +} + +/// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what +/// it gave. +fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc, Out)> { let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?; let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?); let given = paints.given(); @@ -165,19 +244,20 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h)) && *k == key { - return out.clone(); + return out.clone().map(|o| (plugin, o)); } - let r = run(&plugin, resolved, path, bounds, rule, given); + let scope = paints.scope(); + let r = run(&plugin, resolved, path, bounds, rule, given, scope); // A drawing that was cancelled (an export stopped half way) says nothing about the plug-in: // the next drawing runs it again. if matches!(r, Err(Error::Cancelled)) { return None; } - note(id, &r); + note(id, &plugin, r.as_ref().map(|_| ())); let out = r.ok(); let size = weight(&key, out.as_ref()); if size > CACHE_ANCHORS / 4 { - return out; + return out.map(|o| (plugin, o)); } let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); let cache = cache.get_or_insert_with(Cache::default); @@ -188,22 +268,28 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref())); } cache.anchors += size; - out + out.map(|o| (plugin, o)) } -/// One run of `plugin` on `path`, with the object's paints as `given`. -fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given) -> Result { +/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect +/// that declares `paintOutput` its pieces, checked for an effect in `scope`. +fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result { let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]); params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item}); - let rule = match rule { + let rule_name = match rule { FillRule::NonZero => "nonzero", FillRule::EvenOdd => "evenodd", }; - let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b, "fills": given.fills, "strokes": given.strokes}]}); + let input = + json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]}); let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms)); let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) }; let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }; - Ok(output_geometry(decode(&plugin.run_limited(&input, ¶ms, &limits)?)?)) + let output = plugin.run_limited(&input, ¶ms, &limits)?; + if plugin.manifest().paint_output { + return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?))); + } + Ok(Out::Path(output_geometry(decode(&output)?))) } #[cfg(test)] diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 9e6f9e934..17d87a7df 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -18,7 +18,8 @@ //! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns //! the objects to keep, change or add, applied to the document as one edit //! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each -//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole +//! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns +//! ([`painted`]); an **exporter** ([`export`]) writes the whole //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show //! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is @@ -30,6 +31,7 @@ pub mod effect; pub mod export; pub mod manifest; pub mod objects; +pub mod painted; pub mod permissions; pub mod registry; mod runtime; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 75a76cdbb..8907b18c9 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -260,6 +260,18 @@ pub struct Manifest { /// How much a run may compute: only a filter may be [`Budget::Heavy`]. #[serde(default, skip_serializing_if = "Budget::is_light")] pub budget: Budget, + /// A live effect at `api` 2 that returns painted pieces ([`crate::painted`]): `true` when the + /// manifest has `"paintOutput": true`, the only value the key may hold. + #[serde(default, rename = "paintOutput", deserialize_with = "true_only", skip_serializing_if = "std::ops::Not::not")] + pub paint_output: bool, +} + +/// Reads a key that may only be `true` when present (`paintOutput`). +fn true_only<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + match Value::deserialize(d)? { + Value::Bool(true) => Ok(true), + _ => Err(serde::de::Error::custom("`paintOutput` may only be true")), + } } impl Manifest { @@ -288,6 +300,7 @@ impl Manifest { params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, + paint_output: false, } } @@ -310,6 +323,9 @@ impl Manifest { if let Some(names) = &self.permissions { permissions::validate(self.kind, names)?; } + if self.paint_output && (self.kind != Kind::Effect || self.api < 2) { + return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into())); + } self.validate_extensions()?; match (self.budget, self.kind) { (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} @@ -654,7 +670,7 @@ mod tests { (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), (r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"), (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), - (r#"{"id":"a","name":"x","kind":"filter","api":2}"#, "`api` is 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"), (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), ] { @@ -663,6 +679,39 @@ mod tests { } } + /// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other + /// kind or level, and `false`, `null` or another type, is refused naming the key. Without the + /// key, at any level, the effect returns geometry only. + #[test] + fn only_a_level_2_live_effect_declares_paint_output() { + let painted = Manifest::parse(br#"{"id":"org.example.tone","name":"Tone","kind":"effect","api":2,"paintOutput":true}"#).unwrap(); + assert!(painted.paint_output); + assert_eq!(serde_json::to_value(&painted).unwrap()["paintOutput"], json!(true)); + let listed = + Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read","effect.render"]}"#) + .unwrap(); + assert!(listed.paint_output); + for plain in [r#"{"id":"a","name":"x","kind":"effect","api":2}"#, r#"{"id":"a","name":"x","kind":"effect"}"#] { + let m = Manifest::parse(plain.as_bytes()).unwrap(); + assert!(!m.paint_output, "{plain}"); + assert!(serde_json::to_value(&m).unwrap().get("paintOutput").is_none(), "{plain}"); + } + for bad in [ + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":false}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":null}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":1}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":"true"}"#, + r#"{"id":"a","name":"x","kind":"effect","paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"effect","api":1,"paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"filter","api":2,"paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"exporter","api":2,"extensions":["plt"],"paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read"]}"#, + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains("paintOutput") || e.contains("effect.render"), "{bad}: {e}"); + } + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 037591f8b..011d8a7d0 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -162,11 +162,11 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default() } -fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { +pub(crate) fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { Error::Output(format!("{at}: {msg}")) } -fn check_point(p: Point, at: &str) -> Result<()> { +pub(crate) fn check_point(p: Point, at: &str) -> Result<()> { if p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD { Ok(()) } else { @@ -176,7 +176,7 @@ fn check_point(p: Point, at: &str) -> Result<()> { /// A path from plug-in output: native `PathData` JSON, every coordinate finite and in range, empty /// subpaths dropped; `anchors` counts towards [`MAX_ANCHORS`]. -fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result { +pub(crate) fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result { let mut path: PathData = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; path.subpaths.retain(|sp| !sp.anchors.is_empty()); *anchors = anchors.saturating_add(path.anchor_count()); @@ -486,7 +486,7 @@ fn admit_paint(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Resul /// Sets the paints of `ap`'s fills (`fill`) or strokes to `new` (a fill keeps everything but its /// paint), in paint order: extra items are removed (from the top), missing ones added above the /// last item of their kind (fills at the bottom, strokes on top when the object has none). -fn set_items(ap: &mut Appearance, fill: bool, new: Vec) { +pub(crate) fn set_items(ap: &mut Appearance, fill: bool, new: Vec) { let idx: Vec = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect(); for &i in idx.iter().skip(new.len()).rev() { ap.remove_item(i); @@ -513,7 +513,7 @@ fn set_items(ap: &mut Appearance, fill: bool, new: Vec) { } /// Gives stroke `s` the paint of `o` and each of its weight, join, cap and miter limit it has. -fn style(s: &mut StrokeLayer, o: OutStroke) { +pub(crate) fn style(s: &mut StrokeLayer, o: OutStroke) { s.paint = o.paint; s.width = o.width.unwrap_or(s.width); s.join = o.join.unwrap_or(s.join); diff --git a/crates/plugins/src/painted.rs b/crates/plugins/src/painted.rs new file mode 100644 index 000000000..700936ff0 --- /dev/null +++ b/crates/plugins/src/painted.rs @@ -0,0 +1,323 @@ +//! What a live effect that declares `"paintOutput": true` (at `api` 2) returns: painted pieces. +//! +//! Such an effect returns `{"objects": [...]}` as any live effect does, but each entry is a piece +//! drawn by itself, in the order returned, with paints of its own: `{"path", "fillRule"?, +//! "fills"?, "strokes"?}`. Fills and strokes take the forms a filter returns ([`crate::objects`]). +//! A piece without `fills` (or `strokes`) keeps the ones it was given; an empty list paints none. +//! An effect on a fill may return only `fills`, one on a stroke only `strokes`. +//! +//! The result is checked whole before any of it is used ([`decode`]): every number finite and in +//! range (nothing is clamped), every key known, and every swatch and pattern a paint names in the +//! document ([`check_references`]). One fault refuses the whole result, naming the field. + +use serde_json::Value; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, LineCap, LineJoin}; +use vectorcraft_geom::{FillRule, PathData}; + +use crate::objects::{MAX_COORD, MAX_ITEMS, MAX_OBJECTS, MAX_STROKE_WIDTH, MITER_LIMIT, OutStroke, check_point, out_err}; +use crate::{Error, Result}; + +/// The keys a piece may have. `type` and `bounds` are read back from the input as it was given and +/// say nothing about the piece. +const PIECE_KEYS: [&str; 6] = ["type", "path", "fillRule", "bounds", "fills", "strokes"]; +/// The keys a stroke of a piece may have. +const STROKE_KEYS: [&str; 5] = ["paint", "width", "join", "cap", "miterLimit"]; + +/// One piece a live effect returned: its geometry and fill rule, and its fills and strokes in paint +/// order (`None`: the ones it was given). +#[derive(Clone, Debug, PartialEq)] +pub struct Piece { + pub path: PathData, + pub rule: FillRule, + pub fills: Option>, + pub strokes: Option>, +} + +/// Where in an appearance the effect sits, which says what its pieces may paint. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// One of the object's effects: a piece may return fills and strokes. + Object, + /// One of a fill's own effects: a piece may return only `fills`. + Fill, + /// One of a stroke's own effects: a piece may return only `strokes`. + Stroke, +} + +/// Parses and checks a painted result: `{"objects": [...]}`, or `{"error": "message"}` when the +/// plug-in declined. `rule` is the fill rule a piece without `fillRule` takes. Any other top-level +/// key, an unknown key in a piece or stroke, a list or count past its limit, a number out of range +/// or a paint of the wrong form refuses the whole result, naming the field. +pub fn decode(bytes: &[u8], scope: Scope, rule: FillRule) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| out_err("output", format!("not JSON: {e}")))?; + if let Some(e) = crate::objects::declined(&v) { + return Err(e); + } + let top = v.as_object().ok_or_else(|| out_err("output", "expected {\"objects\": [...]}"))?; + if let Some(k) = top.keys().find(|k| !matches!(k.as_str(), "objects" | "error")) { + return Err(out_err(k, "a painted live effect returns only `objects`")); + } + let objects = top.get("objects").and_then(Value::as_array).ok_or_else(|| out_err("objects", "expected {\"objects\": [...]}"))?; + if objects.len() > MAX_OBJECTS { + return Err(out_err("objects", format!("{} objects (limit {MAX_OBJECTS})", objects.len()))); + } + let mut anchors = 0; + objects.iter().enumerate().map(|(i, o)| piece(o, &format!("objects[{i}]"), scope, rule, &mut anchors)).collect() +} + +fn piece(v: &Value, at: &str, scope: Scope, rule: FillRule, anchors: &mut usize) -> Result { + let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?; + if let Some(k) = o.keys().find(|k| !PIECE_KEYS.contains(&k.as_str())) { + return Err(out_err(&format!("{at}.{k}"), "a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes`")); + } + if let Some(t) = o.get("type") + && !matches!(t.as_str(), Some("path" | "compound")) + { + return Err(out_err(&format!("{at}.type"), "`type` must be \"path\" or \"compound\"")); + } + let path = o.get("path").ok_or_else(|| out_err(&format!("{at}.path"), "each piece needs a `path`"))?; + // At most MAX_ANCHORS over all pieces. + let path = crate::objects::decode_path(path, &format!("{at}.path"), anchors)?; + let rule = match o.get("fillRule") { + None => rule, + Some(r) => match r.as_str() { + Some("nonzero") => FillRule::NonZero, + Some("evenodd") => FillRule::EvenOdd, + _ => return Err(out_err(&format!("{at}.fillRule"), "`fillRule` must be \"nonzero\" or \"evenodd\"")), + }, + }; + let list = |key: &str| -> Result>> { + match o.get(key) { + None => Ok(None), + Some(Value::Array(a)) if a.len() <= MAX_ITEMS => Ok(Some(a)), + Some(_) => Err(out_err(&format!("{at}.{key}"), format!("`{key}` must be an array of at most {MAX_ITEMS} entries"))), + } + }; + let (fills, strokes) = (list("fills")?, list("strokes")?); + match scope { + Scope::Fill if strokes.is_some() => return Err(out_err(&format!("{at}.strokes"), "an effect on a fill returns only `fills`")), + Scope::Stroke if fills.is_some() => return Err(out_err(&format!("{at}.fills"), "an effect on a stroke returns only `strokes`")), + _ => {} + } + let fills = fills.map(|f| f.iter().enumerate().map(|(i, p)| paint(p, &format!("{at}.fills[{i}]"))).collect::>>()).transpose()?; + let strokes = + strokes.map(|s| s.iter().enumerate().map(|(i, s)| stroke(s, &format!("{at}.strokes[{i}]"))).collect::>>()).transpose()?; + Ok(Piece { path, rule, fills, strokes }) +} + +/// A stroke `{paint, width?, join?, cap?, miterLimit?}`: a width of 0–[`MAX_STROKE_WIDTH`] and a +/// miter limit in [`MITER_LIMIT`]. +fn stroke(v: &Value, at: &str) -> Result { + let s = v.as_object().ok_or_else(|| out_err(at, "a stroke must be {\"paint\", \"width\"?, \"join\"?, \"cap\"?, \"miterLimit\"?}"))?; + if let Some(k) = s.keys().find(|k| !STROKE_KEYS.contains(&k.as_str())) { + return Err(out_err(&format!("{at}.{k}"), "a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit`")); + } + let number = |key: &str, range: std::ops::RangeInclusive| -> Result> { + let Some(v) = s.get(key) else { return Ok(None) }; + match v.as_f64().filter(|x| x.is_finite() && range.contains(x)) { + Some(x) => Ok(Some(x)), + None => Err(out_err(&format!("{at}.{key}"), format!("`{key}` must be a number from {} to {}", range.start(), range.end()))), + } + }; + let join = match s.get("join").map(Value::as_str) { + None => None, + Some(Some("miter")) => Some(LineJoin::Miter), + Some(Some("round")) => Some(LineJoin::Round), + Some(Some("bevel")) => Some(LineJoin::Bevel), + Some(_) => return Err(out_err(&format!("{at}.join"), "`join` must be \"miter\", \"round\" or \"bevel\"")), + }; + let cap = match s.get("cap").map(Value::as_str) { + None => None, + Some(Some("butt")) => Some(LineCap::Butt), + Some(Some("round")) => Some(LineCap::Round), + Some(Some("square")) => Some(LineCap::Square), + Some(_) => return Err(out_err(&format!("{at}.cap"), "`cap` must be \"butt\", \"round\" or \"square\"")), + }; + let paint = paint(s.get("paint").unwrap_or(&Value::Null), &format!("{at}.paint"))?; + Ok(OutStroke { paint, width: number("width", 0.0..=MAX_STROKE_WIDTH)?, join, cap, miter_limit: number("miterLimit", MITER_LIMIT)? }) +} + +/// Is `x` finite and in `lo..=hi`? +fn within(x: f64, lo: f64, hi: f64) -> bool { + x.is_finite() && (lo..=hi).contains(&x) +} + +fn in_range(x: f32, lo: f32, hi: f32, at: &str, what: &str) -> Result<()> { + if within(f64::from(x), f64::from(lo), f64::from(hi)) { Ok(()) } else { Err(out_err(at, format!("{what} must be from {lo} to {hi}"))) } +} + +/// A color with every component in range: 0–1, or Lab's L 0–100 and a, b −128–127. +fn color(c: &Color, at: &str) -> Result<()> { + let unit = |x: f32| in_range(x, 0.0, 1.0, at, "a color component"); + match *c { + Color::Rgb { r, g, b } => [r, g, b].into_iter().try_for_each(unit), + Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().try_for_each(unit), + Color::Gray { k } => unit(k), + Color::Lab { l, a, b } => { + in_range(l, 0.0, 100.0, at, "Lab's L")?; + in_range(a, -128.0, 127.0, at, "Lab's a")?; + in_range(b, -128.0, 127.0, at, "Lab's b") + } + } +} + +/// A paint as the filter form has it (`null`: none), checked rather than clamped: colors, tints, +/// stops, opacities, midpoints and placements in range. +fn paint(v: &Value, at: &str) -> Result { + if v.is_null() { + return Ok(Paint::None); + } + let p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; + let unit = |x: f32, at: &str, what: &str| in_range(x, 0.0, 1.0, at, what); + match &p { + Paint::None => {} + Paint::Solid { color: c, tint, .. } => { + color(c, &format!("{at}.color"))?; + unit(*tint, &format!("{at}.tint"), "`tint`")?; + } + Paint::Gradient(g) => { + let stops = &g.gradient.stops; + if stops.is_empty() || stops.len() > MAX_ITEMS { + return Err(out_err(&format!("{at}.gradient.stops"), format!("a gradient needs 1-{MAX_ITEMS} stops"))); + } + for (i, s) in stops.iter().enumerate() { + let at = format!("{at}.gradient.stops[{i}]"); + unit(s.offset, &format!("{at}.offset"), "`offset`")?; + unit(s.opacity, &format!("{at}.opacity"), "`opacity`")?; + in_range(s.midpoint, 0.13, 0.87, &format!("{at}.midpoint"), "`midpoint`")?; + unit(s.tint, &format!("{at}.tint"), "`tint`")?; + color(&s.color, &format!("{at}.color"))?; + for x in s.plates.iter().flatten() { + unit(*x, &format!("{at}.plates"), "a plate")?; + } + } + if stops.windows(2).any(|w| w[1].offset < w[0].offset) { + return Err(out_err(&format!("{at}.gradient.stops"), "stops must be in order of `offset`")); + } + if !g.angle.is_finite() { + return Err(out_err(&format!("{at}.angle"), "`angle` must be a number")); + } + if let Some(geom) = &g.geom { + check_point(geom.start, &format!("{at}.geom.start"))?; + check_point(geom.end, &format!("{at}.geom.end"))?; + if let Some(f) = geom.focal { + check_point(f, &format!("{at}.geom.focal"))?; + } + if !within(geom.aspect, 0.01, 100.0) { + return Err(out_err(&format!("{at}.geom.aspect"), "`aspect` must be from 0.01 to 100")); + } + } + if let Some(ff) = &g.freeform { + if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) { + return Err(out_err(&format!("{at}.freeform"), format!("a freeform gradient holds at most {MAX_ITEMS} points and lines"))); + } + if ff.lines.iter().flatten().any(|i| *i >= ff.points.len()) { + return Err(out_err(&format!("{at}.freeform.lines"), "a freeform gradient line names a point it doesn't have")); + } + for (i, pt) in ff.points.iter().enumerate() { + let at = format!("{at}.freeform.points[{i}]"); + check_point(pt.at, &format!("{at}.at"))?; + color(&pt.color, &format!("{at}.color"))?; + unit(pt.opacity, &format!("{at}.opacity"), "`opacity`")?; + unit(pt.spread, &format!("{at}.spread"), "`spread`")?; + } + } + } + Paint::Pattern { xf, .. } => { + if !xf.as_coeffs().iter().all(|c| within(*c, -MAX_COORD, MAX_COORD)) { + return Err(out_err(&format!("{at}.xf"), "pattern transform out of range")); + } + } + } + Ok(p) +} + +/// Checks that every swatch a solid paint, gradient or gradient stop of `pieces` links to is a +/// swatch of `doc`, and every pattern a pattern paint names is one of its patterns. Without a +/// document (`None`), any swatch or pattern a piece names is refused. +pub fn check_references(pieces: &[Piece], doc: Option<&Document>) -> Result<()> { + let swatch = |name: &Option, at: &str| match name { + Some(n) if doc.and_then(|d| d.swatch(n)).is_none() => Err(out_err(at, format!("the document has no swatch {n:?}"))), + _ => Ok(()), + }; + let check = |p: &Paint, at: &str| -> Result<()> { + match p { + Paint::None => Ok(()), + Paint::Solid { swatch: s, .. } => swatch(s, &format!("{at}.swatch")), + Paint::Gradient(g) => { + swatch(&g.swatch, &format!("{at}.swatch"))?; + g.gradient.stops.iter().enumerate().try_for_each(|(i, s)| swatch(&s.swatch, &format!("{at}.gradient.stops[{i}].swatch"))) + } + Paint::Pattern { pattern, .. } if doc.and_then(|d| d.pattern(pattern)).is_none() => { + Err(out_err(&format!("{at}.pattern"), format!("the document has no pattern {pattern:?}"))) + } + Paint::Pattern { .. } => Ok(()), + } + }; + for (i, p) in pieces.iter().enumerate() { + for (k, f) in p.fills.iter().flatten().enumerate() { + check(f, &format!("objects[{i}].fills[{k}]"))?; + } + for (k, s) in p.strokes.iter().flatten().enumerate() { + check(&s.paint, &format!("objects[{i}].strokes[{k}].paint"))?; + } + } + Ok(()) +} + +/// How many paints, gradient stops and freeform points `pieces` hold: what a cache counts them as +/// besides their anchors. +pub(crate) fn paint_count(pieces: &[Piece]) -> usize { + let one = |p: &Paint| match p { + Paint::Gradient(g) => 1 + g.gradient.stops.len() + g.freeform.as_ref().map_or(0, |f| f.points.len()), + _ => 1, + }; + pieces.iter().map(|p| p.fills.iter().flatten().map(one).sum::() + p.strokes.iter().flatten().map(|s| one(&s.paint)).sum::()).sum() +} + +/// The appearance piece `p` of an object-level effect is drawn with: `base` (the object's) with +/// its fills, then its strokes, replaced by the piece's where it has them, as a filter's output +/// sets them (an existing item keeps everything but what the piece gives; new ones take the +/// defaults). +pub fn appearance(base: &Appearance, p: &Piece) -> Appearance { + let mut ap = base.clone(); + if let Some(fills) = &p.fills { + crate::objects::set_items(&mut ap, true, fills.iter().cloned().map(OutStroke::paint).collect()); + } + if let Some(strokes) = &p.strokes { + crate::objects::set_items(&mut ap, false, strokes.clone()); + } + ap +} + +/// The items piece `p` of an effect on `item` (a fill or a stroke) is drawn with, in paint order: +/// `item` itself when the piece keeps it, else one copy of it for each paint or stroke the piece +/// gives, with that paint (and that stroke's weight, join, cap and miter limit where given). +pub fn items(item: &AppearanceItem, p: &Piece) -> Vec { + match item { + AppearanceItem::Fill(f) => match &p.fills { + None => vec![item.clone()], + Some(fills) => fills.iter().map(|paint| AppearanceItem::Fill(vectorcraft_doc::FillLayer { paint: paint.clone(), ..f.clone() })).collect(), + }, + AppearanceItem::Stroke(s) => match &p.strokes { + None => vec![item.clone()], + Some(strokes) => strokes + .iter() + .map(|o| { + let mut s = s.clone(); + crate::objects::style(&mut s, o.clone()); + AppearanceItem::Stroke(s) + }) + .collect(), + }, + } +} + +/// An error naming `name`, the plug-in, before what went wrong (`plugin.info`'s `lastError`). +pub(crate) fn named(name: &str, e: &Error) -> String { + format!("{name}: {e}") +} + +#[cfg(test)] +mod tests; diff --git a/crates/plugins/src/painted/tests.rs b/crates/plugins/src/painted/tests.rs new file mode 100644 index 000000000..ff72d330f --- /dev/null +++ b/crates/plugins/src/painted/tests.rs @@ -0,0 +1,149 @@ +use serde_json::json; +use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, StrokeLayer}; + +use super::*; + +const SQUARE: &str = r#"{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]},{"p":[0,10]}]}]}"#; + +fn red() -> Value { + serde_json::to_value(Paint::solid(Color::rgb(1.0, 0.0, 0.0))).unwrap() +} + +fn out(pieces: Value) -> Vec { + serde_json::to_vec(&json!({ "objects": pieces })).unwrap() +} + +fn square() -> Value { + serde_json::from_str(SQUARE).unwrap() +} + +/// Each piece keeps its own paints in the order returned; a piece without `fills` or `strokes` +/// keeps the ones it was given (`None`) and an empty list paints none. +#[test] +fn pieces_keep_their_own_paints_in_order() { + let blue = serde_json::to_value(Paint::solid(Color::rgb(0.0, 0.0, 1.0))).unwrap(); + let pieces = decode( + &out(json!([ + {"path": square(), "fills": [red()], "strokes": [{"paint": blue, "width": 4, "join": "round", "cap": "square", "miterLimit": 2}]}, + {"type": "path", "path": square(), "fillRule": "evenodd", "fills": [], "bounds": [0, 0, 10, 10]}, + {"path": square()}, + ])), + Scope::Object, + FillRule::NonZero, + ) + .unwrap(); + assert_eq!(pieces.len(), 3); + assert_eq!(pieces[0].fills, Some(vec![Paint::solid(Color::rgb(1.0, 0.0, 0.0))])); + let s = &pieces[0].strokes.as_ref().unwrap()[0]; + assert_eq!((s.width, s.join, s.cap, s.miter_limit), (Some(4.0), Some(LineJoin::Round), Some(LineCap::Square), Some(2.0))); + assert_eq!((pieces[1].rule, pieces[1].fills.as_deref(), &pieces[1].strokes), (FillRule::EvenOdd, Some(&[][..]), &None)); + assert_eq!((pieces[2].rule, &pieces[2].fills, &pieces[2].strokes), (FillRule::NonZero, &None, &None)); +} + +/// What a piece is drawn with: the object's appearance with the paints the piece gives in place of +/// its own (kept, cleared or replaced), and for an effect on one fill or stroke, copies of that item. +#[test] +fn a_piece_s_appearance_inherits_or_replaces() { + let base = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::WHITE))), + AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 3.0)), + ], + ..Appearance::default() + }; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let piece = + |fills: Option>, strokes: Option>| Piece { path: PathData::default(), rule: FillRule::NonZero, fills, strokes }; + assert_eq!(appearance(&base, &piece(None, None)), base, "inherited"); + let ap = appearance(&base, &piece(Some(vec![red.clone()]), Some(vec![]))); + assert_eq!(ap.items.len(), 1); + assert_eq!(ap.items[0].paint(), &red); + let ap = appearance(&base, &piece(None, Some(vec![OutStroke { width: Some(8.0), ..OutStroke::paint(red.clone()) }]))); + let AppearanceItem::Stroke(s) = &ap.items[1] else { panic!("a stroke") }; + assert_eq!((s.width, &s.paint), (8.0, &red)); + let two = items(&base.items[0], &piece(Some(vec![red.clone(), Paint::None]), None)); + assert_eq!(two.iter().map(|i| i.paint().clone()).collect::>(), [red.clone(), Paint::None]); + assert_eq!(items(&base.items[1], &piece(None, None)), vec![base.items[1].clone()]); + let none = items(&base.items[1], &piece(None, Some(vec![]))); + assert!(none.is_empty()); +} + +/// An effect on a fill returns only fills, one on a stroke only strokes. +#[test] +fn an_item_s_effect_paints_only_its_kind() { + let fills = out(json!([{"path": square(), "fills": [red()]}])); + let strokes = out(json!([{"path": square(), "strokes": [{"paint": red()}]}])); + assert!(decode(&fills, Scope::Fill, FillRule::NonZero).is_ok()); + assert!(decode(&strokes, Scope::Stroke, FillRule::NonZero).is_ok()); + let e = decode(&strokes, Scope::Fill, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].strokes"), "{e}"); + let e = decode(&fills, Scope::Stroke, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills"), "{e}"); +} + +/// Every fault refuses the whole result and names its field; nothing is clamped. +#[test] +fn faults_refuse_the_whole_result_by_field() { + let ok = json!({"path": square(), "fills": [red()]}); + let stop = |offset: f64| json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": offset, "color": {"model": "rgb", "r": 0, "g": 0, "b": 0}}]}}); + let many: Vec = (0..=MAX_ITEMS).map(|_| red()).collect(); + let stops: Vec = (0..=MAX_ITEMS).map(|_| json!({"offset": 0, "color": {"model": "gray", "k": 0}})).collect(); + for (piece, at) in [ + (json!({"path": square(), "fills": [{"type": "plaid"}]}), "objects[1].fills[0]"), + (json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "rgb", "r": 2, "g": 0, "b": 0}}]}), "objects[1].fills[0].color"), + (json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "rgb", "r": 1e300, "g": 0, "b": 0}}]}), "objects[1].fills[0].color"), + (json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}, "tint": 1.5}]}), "objects[1].fills[0].tint"), + (json!({"path": square(), "strokes": [{"paint": red(), "width": 1001}]}), "objects[1].strokes[0].width"), + (json!({"path": square(), "strokes": [{"paint": red(), "width": -1}]}), "objects[1].strokes[0].width"), + (json!({"path": square(), "strokes": [{"paint": red(), "miterLimit": 0.5}]}), "objects[1].strokes[0].miterLimit"), + (json!({"path": square(), "strokes": [{"paint": red(), "join": "wobbly"}]}), "objects[1].strokes[0].join"), + (json!({"path": square(), "strokes": [{"paint": red(), "dashes": [1, 2]}]}), "objects[1].strokes[0].dashes"), + (json!({"path": square(), "fills": [stop(1.5)]}), "objects[1].fills[0].gradient.stops[0].offset"), + ( + json!({"path": square(), "fills": [{"type": "gradient", "gradient": {"kind": "Linear", "stops": stops}}]}), + "objects[1].fills[0].gradient.stops", + ), + (json!({"path": square(), "fills": many}), "objects[1].fills"), + (json!({"path": square(), "fills": [red()], "opacity": 0.5}), "objects[1].opacity"), + (json!({"path": square(), "effects": []}), "objects[1].effects"), + (json!({"path": square(), "guide": true}), "objects[1].guide"), + (json!({"path": square(), "locked": true}), "objects[1].locked"), + (json!({"fills": [red()]}), "objects[1].path"), + (json!({"path": square(), "fillRule": "inside"}), "objects[1].fillRule"), + (json!({"path": {"subpaths": [{"closed": true, "anchors": [{"p": [1e9, 0]}]}]}}), "objects[1].path"), + ] { + let e = decode(&out(json!([ok, piece])), Scope::Object, FillRule::NonZero).unwrap_err(); + let e = e.to_string(); + assert!(e.contains(&format!("{at}:")), "{at}: {e}"); + } + let e = decode(&serde_json::to_vec(&json!({"objects": [ok], "documentData": {}})).unwrap(), Scope::Object, FillRule::NonZero).unwrap_err(); + assert!(e.to_string().contains("documentData"), "{e}"); + let declined = decode(br#"{"error":"nothing to do"}"#, Scope::Object, FillRule::NonZero).unwrap_err(); + assert_eq!(declined, Error::Failed("nothing to do".into())); +} + +/// The swatches and patterns a piece's paints name must be in the document. +#[test] +fn named_swatches_and_patterns_must_exist() { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(Swatch { name: "Brand".into(), paint: Paint::solid(Color::rgb(0.2, 0.4, 0.6)), global: true, spot: false }); + let linked = |name: &str| json!({"type": "solid", "color": {"model": "rgb", "r": 0.2, "g": 0.4, "b": 0.6}, "swatch": name}); + let pieces = |pieces: Value| decode(&out(pieces), Scope::Object, FillRule::NonZero).unwrap(); + let check = |p: Value| check_references(&pieces(p), Some(&doc)); + assert!(check(json!([{"path": square(), "fills": [linked("Brand")]}])).is_ok()); + let e = check(json!([{"path": square()}, {"path": square(), "strokes": [{"paint": linked("Gone")}]}])).unwrap_err().to_string(); + assert!(e.contains("objects[1].strokes[0].paint.swatch"), "{e}"); + let stop = + json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": {"model": "gray", "k": 0}, "swatch": "Gone"}]}}); + let e = check(json!([{"path": square(), "fills": [stop]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[0].swatch"), "{e}"); + let e = check(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].pattern"), "{e}"); + // Without a document to look them up in, no swatch or pattern may be named. + assert!(check_references(&pieces(json!([{"path": square(), "fills": [red()]}])), None).is_ok()); + let e = check_references(&pieces(json!([{"path": square(), "fills": [linked("Brand")]}])), None).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].swatch"), "{e}"); + let e = check_references(&pieces(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])), None).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].pattern"), "{e}"); +} diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 1d6b0664e..18d8da809 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -12,8 +12,8 @@ use serde::{Deserialize, Serialize}; use crate::{Error, Kind, Result}; /// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most -/// this. -pub const API_LEVEL: u32 = 1; +/// this. Level 2 adds a live effect's `paintOutput` ([`crate::painted`]). +pub const API_LEVEL: u32 = 2; /// Most permissions a manifest may list. pub const MAX_PERMISSIONS: usize = 16; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5c9b74c85..64ae87821 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -597,6 +597,7 @@ impl PluginIntField { params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), + paint_output: false, }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 324750c44..d635f91ce 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5825,6 +5825,7 @@ digits Malayalam Malaiàlam Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte Write the document as a file in its own format Escriure el document com un fitxer en un format propi @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` només és per a un efecte dinàmic (`kind` "effect") amb `api` 2 @msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos @msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un complement del tipus `{_1}` necessita el permís `{_2}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 78e9cdd6e..4e467f412 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5802,6 +5802,7 @@ axis Y Axis osa Y Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` je jen pro živý efekt (`kind` "effect") s `api` 2 @msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění @msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` zásuvný modul druhu `{_1}` potřebuje oprávnění `{_2}` diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b22912c68..afdefcc0d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5384,6 +5384,7 @@ axis Y Axis Y-Achse Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` gilt nur für einen Live-Effekt (`kind` "effect") mit `api` 2 @msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein @msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` ein Plug-in der Art `{_1}` braucht die Berechtigung `{_2}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 71e4f499f..2413df55c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5753,6 +5753,7 @@ axis Y Axis Άξονας Y Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 το `paintOutput` ισχύει μόνο για ζωντανό εφέ (`kind` "effect") με `api` 2 @msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων @msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` ένα πρόσθετο τύπου `{_1}` χρειάζεται το δικαίωμα `{_2}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 72ad8d982..287354443 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5743,6 +5743,7 @@ axis Y Axis Eje Y Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto Write the document as a file in its own format Escribir el documento como un archivo en su propio formato @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` solo es para un efecto dinámico (`kind` "effect") con `api` 2 @msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos @msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un plugin de tipo `{_1}` necesita el permiso `{_2}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f4eeaaff1..5ea7377d7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5832,6 +5832,7 @@ axis Y Axis Y-akseli Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` on vain reaaliaikaiselle tehosteelle (`kind` "effect") tasolla `api` 2 @msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä @msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` lajin `{_1}` laajennus tarvitsee käyttöoikeuden `{_2}` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d9e77d68d..8e497f293 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5797,6 +5797,7 @@ axis Y Axis Axe Y Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué Write the document as a file in its own format Écrire le document dans un fichier à son propre format @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` est réservé à un effet dynamique (`kind` "effect") avec `api` 2 @msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations @msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b5791f4a4..069177641 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5199,6 +5199,7 @@ Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` केवल `api` 2 पर लाइव इफ़ेक्ट (`kind` "effect") के लिए है @msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए @msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` प्रकार के प्लग-इन को `{_2}` अनुमति चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 58d649442..1219c19ae 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5785,6 +5785,7 @@ axis Y Axis Y tengely Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 a `paintOutput` csak élő effektushoz (`kind` "effect") használható, `api` 2 mellett @msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie @msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` egy `{_1}` fajtájú bővítménynek szüksége van a(z) `{_2}` engedélyre diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d11fcb22d..1d0f3d256 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5418,6 +5418,7 @@ axis Y Axis Asse Y Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto Write the document as a file in its own format Scrivere il documento come file in un formato proprio @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` vale solo per un effetto dinamico (`kind` "effect") con `api` 2 @msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni @msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un plug-in di tipo `{_1}` richiede l’autorizzazione `{_2}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6bb917eec..a7e1bcca6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5483,6 +5483,7 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` は `api` 2 のライブ効果 (`kind` "effect") 専用です @msg `grant` must be a list of permission names `grant` は権限名のリストにしてください @msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています @msg a plug-in of kind `{_1}` needs the permission `{_2}` 種類 `{_1}` のプラグインには権限 `{_2}` が必要です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 483955091..6ff4427d1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5725,6 +5725,7 @@ axis Y Axis Y ღერძი Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` მხოლოდ ცოცხალი ეფექტისთვისაა (`kind` "effect") `api` 2-ზე @msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს @msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` სახის პლაგინს სჭირდება ნებართვა `{_2}` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9a6a465c9..5cc2a4740 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5379,6 +5379,7 @@ axis Y Axis Y축 Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput`은 `api` 2의 라이브 효과(`kind` "effect")에만 쓸 수 있습니다 @msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 @msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 종류의 플러그인에는 `{_2}` 권한이 필요합니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f98e35520..9f0f0375c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5655,6 +5655,7 @@ axis Y Axis Oś Y Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` dotyczy tylko efektu na żywo (`kind` "effect") z `api` 2 @msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień @msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` wtyczka rodzaju `{_1}` potrzebuje uprawnienia `{_2}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ce6919f8d..3f34dac48 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5717,6 +5717,7 @@ axis Y Axis Eixo Y Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` é só para um efeito dinâmico (`kind` "effect") com `api` 2 @msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões @msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` um plug-in do tipo `{_1}` precisa da permissão `{_2}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a1ae4746c..fae6ad832 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5413,6 +5413,7 @@ axis Y Axis Ось Y Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект Write the document as a file in its own format Записывать документ в файл собственного формата @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` допустим только для динамического эффекта (`kind` "effect") с `api` 2 @msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений @msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю вида `{_1}` нужно разрешение `{_2}` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b13141b8e..385b5d0cf 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5825,6 +5825,7 @@ axis Y Axis Y-axel Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` gäller bara en liveeffekt (`kind` "effect") med `api` 2 @msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn @msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` ett plugin av typen `{_1}` behöver behörigheten `{_2}` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d3fc04823..c11452966 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5759,6 +5759,7 @@ axis Y Axis Y ekseni Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` yalnızca `api` 2 düzeyindeki canlı efekt (`kind` "effect") içindir @msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır @msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` türündeki bir eklentinin `{_2}` iznine ihtiyacı vardır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c256b0064..538e155b6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5398,6 +5398,7 @@ axis Y Axis Вісь Y Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект Write the document as a file in its own format Записувати документ у файл власного формату @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` дозволено лише для динамічного ефекту (`kind` "effect") з `api` 2 @msg `grant` must be a list of permission names `grant` має бути списком назв дозволів @msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю виду `{_1}` потрібен дозвіл `{_2}` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 887d9a0f6..f25ba5ac0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5399,6 +5399,7 @@ axis Y Axis Trục Y Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` chỉ dành cho hiệu ứng trực tiếp (`kind` "effect") ở `api` 2 @msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền @msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` plug-in loại `{_1}` cần quyền `{_2}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d99db0948..63acd4acf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5640,6 +5640,7 @@ axis Y Axis Y 轴 Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 Write the document as a file in its own format 将文档写为其自有格式的文件 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` 仅适用于 `api` 2 的实时效果 (`kind` "effect") @msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 类型的增效工具需要权限 `{_2}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb6b40a80..2f8a637c4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5436,6 +5436,7 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 Write the document as a file in its own format 將文件寫成其自有格式的檔案 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` 僅適用於 `api` 2 的即時效果 (`kind` "effect") @msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 類型的增效模組需要權限 `{_2}` diff --git a/docs/mcp.md b/docs/mcp.md index 481570141..98e198dc1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5968,8 +5968,11 @@ them again at startup (`plugin.remove` deletes them there): schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and -`effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): -`document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions +`effect.list` lists it. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api` +and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them +as ordinary paths with those paints; a result it refuses is drawn as if the effect weren't there, and `plugin.info`'s +`lastError` names the plug-in and the refused field. An exporter adds a write-only file format `plugin.` (listed +by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. A failing plug-in returns an error and leaves the document as it was (an export writes no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` diff --git a/docs/plugins.md b/docs/plugins.md index 96f8ac6e3..625ea1316 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -11,7 +11,8 @@ There are three kinds of plug-in: and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path clean-up, halftones, generative patterns and the like. - **Live effects** rewrite one object's geometry each time it is drawn, from its appearance stack, like the - built-in Distort & Transform effects. The art stays editable: move or reshape it and the effect follows. + built-in Distort & Transform effects. The art stays editable: move or reshape it and the effect follows. A live + effect may also paint what it returns ([painted live effects](#painted-live-effects)). - **Exporters** write the whole document in a file format of their own, for pen plotters, cutting machines, laser cutters and the like. File › Export › Export As lists them after the built-in formats, and the host writes the bytes they return: a plug-in still never touches a file. @@ -23,7 +24,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, api, paintOutput?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `api` is the manifest's plug-in API level, and `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | | `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | @@ -140,8 +141,9 @@ capability model proposed for PhotoCraft plug-ins - The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. -- `api` is the plug-in API level the manifest is written for: `1` (the default) is the only level this host - implements, and a higher one is refused. +- `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds a live effect's + `paintOutput`. A higher one is refused. A level 2 effect that declares `paintOutput` needs no other permission than + `doc.read` and `effect.render`. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -158,8 +160,10 @@ takes as many instructions the first time as the next. Charged to the run, as wa could fail it: when the 20 M-instruction slice the run was in ran out while a function compiled, wasmi ended the run with an error rather than a pause the host resumes, and the run stopped with most of its budget left. -Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters and input -geometry: a plug-in runs again only when one of them changes. +Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters, input +geometry, fill rule, reference box and the paints the effect is given: a plug-in runs again only when one of them +changes. Failures are cached too. The swatches and patterns a painted result names are checked against the document +each time it is drawn. ## ABI v1 @@ -204,6 +208,11 @@ pointers are offsets into the module's memory. filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. - `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names ([permissions](#permissions)); without it, what its kind implies. +- `paintOutput` (live effects at `api` 2 only): `true` makes the effect return [painted pieces](#painted-live-effects) + instead of geometry alone. `true` is the only value the key takes; `false`, `null`, another type, another kind or + `api` 1 is refused when the module is installed, and the error names `paintOutput`. Without the key the effect returns + geometry, at any level. Hosts at `api` 1 refuse the manifest for its `api`, so such an effect never draws unpainted + there. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. @@ -247,7 +256,8 @@ guides too) and clipping paths are left out, and so are other kinds of object (t touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a -fill's colour to decide its shape, but it changes only the geometry. +fill's colour to decide its shape, but it changes only the geometry, unless it declares `paintOutput` ([painted live +effects](#painted-live-effects)). An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, @@ -351,7 +361,45 @@ document, so it can read what every plug-in keeps there, not only its own. A liv is worked out from the object alone. For a **live effect**, the geometry of every returned object's `path` (their subpaths, in order) replaces the -object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry. +object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry. A live effect +that declares `paintOutput` returns painted pieces instead ([below](#painted-live-effects)). + +#### Painted live effects + +A live effect whose manifest has `"api": 2` and `"paintOutput": true` returns `{"objects": [...]}` too, but each entry +is a piece drawn by itself, in the order returned, with paints of its own: + +```json +{"objects": [ + {"path": {…}, "fills": [{"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0.6, "b": 0.3}}]}, + {"path": {…}, "fillRule": "evenodd", "strokes": [{"paint": null}, {"paint": {…}, "width": 4, "join": "round"}]}, + {"path": {…}} +]} +``` + +- `path` is required; `fillRule` defaults to the object's. `type` and `bounds` may be handed back as given and are + ignored. Any other key (`opacity`, `effects`, `guide`, `locked`, `name`, a top-level `documentData`…) refuses the + result. +- `fills` and `strokes` take the forms a filter returns ([Input](#input)). A piece without `fills` (or `strokes`) is + painted with the ones the effect was given; an empty list paints none. Given ones replace the object's in order, as + a filter's output sets them: an existing fill or stroke keeps its own effects, opacity and blending, and a new stroke + starts at 1 pt, miter join, butt cap, miter limit 10. +- An effect on a fill (`_context.item` `{"fill": i}`) returns only `fills`, which replace that fill on each piece; an + effect on a stroke returns only `strokes`. The object's other fills and strokes stay where they are. +- The effects after it in the stack apply to the pieces together, the reference box being the bounds of all of them. + Bounds, hit tests and raster effects use the pieces as they are drawn. Each piece counts against the budget of the + object's geometry effects as well as its anchors, so all the painted effects in a stack make at most 100,000 pieces + together; a run whose pieces would pass the budget draws its input as it was. +- The result is checked whole before any of it is drawn: the limits of a filter's output (100,000 objects, 4,000,000 + anchors, 256 fills and 256 strokes per piece, 256 stops per gradient), finite numbers in range with nothing clamped + (color components, tints, offsets, opacities, midpoints 0.13 to 0.87, stops in order of `offset`, stroke + widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every pattern named in + the document. One fault refuses the whole result: that step of the stack draws its input as it was, the effect + record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example + `Test Tone: plug-in output: objects[1].strokes[0].width: …`. +- The pieces are never written into the document while it is drawn, measured, previewed, saved or exported. PDF and SVG + export draw them as the canvas does, from a copy of the document. Object › Expand Appearance writes each piece as an + ordinary path or compound path with its fills and strokes, as one undo step. An **exporter** returns `{"dataBase64": "…", "warnings"?: ["…"]}`: the file, in base64, which the host writes as it is, and what the export left out or changed, for the user (at most 100 warnings of at most 500 characters each, shown @@ -388,6 +436,6 @@ per change of their input, parameters or paints (results are cached), not per fr - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open files). -- Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or - symbols. +- Live effects that change opacity, blending or effects (at `api` 2 one may [paint](#painted-live-effects) the pieces + it returns), and filters on type, images or symbols. - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ab98af00d..aaca063ea 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1445,6 +1445,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From d8afed1562b34ecc224351d10ec215b2bbdba4ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:52:39 -0800 Subject: [PATCH 24/36] Plug-ins: painted pieces keep their swatches in Pathfinder and baked art Pathfinder effects, compound shapes, Expand Appearance and pattern expansion now pass the document to the effects they evaluate, so a painted piece that names a swatch or pattern takes part as drawn instead of being refused. A path with a painted effect takes part in Pathfinder and compound shapes as its pieces, each with its own paint, inside the path's opacity and blend mode. The renderer's kept effect art and live evaluations now count the document's swatch and pattern names for objects with a plug-in effect, so deleting or restoring a swatch a piece names redraws the object. --- crates/effects/src/bake.rs | 29 +++--- crates/effects/src/bake/tests.rs | 12 +-- crates/effects/src/group.rs | 92 ++++++++++-------- crates/effects/src/group/tests.rs | 40 ++++---- crates/effects/src/lib.rs | 3 +- crates/effects/src/live.rs | 3 +- crates/effects/src/params.rs | 8 ++ crates/effects/src/reshape.rs | 31 +++--- crates/effects/src/revolve.rs | 2 +- crates/effects/src/solids/tests.rs | 4 +- crates/effects/src/tests.rs | 10 +- crates/engine/src/cmd/compoundshape.rs | 6 +- crates/engine/src/cmd/expand.rs | 33 ++++--- crates/engine/src/cmd/pathops.rs | 2 +- crates/engine/src/tests/compound_shape.rs | 16 +-- crates/engine/src/tests/plugins.rs | 113 ++++++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 2 +- crates/render/src/fx/entries.rs | 18 ++-- crates/render/src/fx/pixels.rs | 10 +- crates/render/src/fx/predicates.rs | 8 +- crates/render/src/live.rs | 37 ++++--- docs/plugins.md | 4 +- 22 files changed, 318 insertions(+), 165 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index cdde7d135..a2cefbd74 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -333,16 +333,16 @@ fn moved_type_group(n: &Node) -> Option { /// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of /// them. `None` when no type stays type. fn container_keeping_type(d: &Document, n: &Node) -> Option { - fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { + fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); if !matches!(c.kind, NodeKind::Text(_)) { if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() { - outline_nested_text(Arc::make_mut(c), clipping); + outline_nested_text(Some(d), Arc::make_mut(c), clipping); } else if c.children().is_some() { - keep(Arc::make_mut(c), moves, clipping, kept); + keep(d, Arc::make_mut(c), moves, clipping, kept); } continue; } @@ -357,7 +357,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { } } None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - None => as_art(None, c, None), + None => as_art(Some(d), c, None), }; if let Some(o) = art { *c = Arc::new(o); @@ -376,11 +376,11 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { } let mut m = crate::reshape::evaluated(Some(d), n); let mut kept = false; - keep(&mut m, &moves, false, &mut kept); + keep(d, &mut m, &moves, false, &mut kept); if !kept { return None; } - reshape_leaves(&mut m, &fx); + reshape_leaves(&mut m, &fx, Some(d)); m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; @@ -530,9 +530,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as /// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path /// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`] -/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor -/// a geometry effect, so bending first changes nothing. -pub fn bake_appearance(n: &Node) -> Option { +/// makes of it, and each copy is baked this way; the swatches and patterns that live-effect plug-ins +/// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to +/// outline nor a geometry effect, so bending first changes nothing. +pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { return Some(m); } @@ -543,9 +544,9 @@ pub fn bake_appearance(n: &Node) -> Option { // patterns: the art Expand Appearance makes of it (each copy an object with its own strokes, // patterns and gradients), each piece baked in turn. if crate::has_transform_art(n) - && let Some(mut m) = reshape(None, n, None) + && let Some(mut m) = reshape(doc, n, None) { - bake_appearance_pieces(&mut m); + bake_appearance_pieces(doc, &mut m); return Some(m); } let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); @@ -601,15 +602,15 @@ pub fn bake_appearance(n: &Node) -> Option { } /// [`bake_appearance`] every path and compound path in `n` that it changes, in place. -fn bake_appearance_pieces(n: &mut Node) { +fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) { if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - if let Some(m) = bake_appearance(n) { + if let Some(m) = bake_appearance(doc, n) { *n = m; } return; } for c in n.children_mut().into_iter().flatten() { - bake_appearance_pieces(Arc::make_mut(c)); + bake_appearance_pieces(doc, Arc::make_mut(c)); } } diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index e16742270..7f933f412 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -67,7 +67,7 @@ fn a_baked_stroke_keeps_its_raster_effects() { s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); s.effects.push(fx("stylize.dropShadow", json!({}))); } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); @@ -82,7 +82,7 @@ fn a_baked_stroke_leaves_dither_behind() { g.gradient.dither = true; s.paint = Paint::Gradient(Box::new(g)); } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; @@ -98,7 +98,7 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; s.opacity = 0.5; } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); @@ -131,7 +131,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { // Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline // is 204 pt wide. let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 2, "{w:?}"); assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}"); @@ -142,7 +142,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { fn a_baked_transform_copy_keeps_its_moved_pattern() { let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY }; let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let xs: Vec = baked_paths(&m) .iter() .map(|p| match p.appearance.fill_paint() { @@ -157,7 +157,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() { fn a_baked_transform_without_its_options_outlines_the_copies_together() { // Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares. let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 1, "{w:?}"); assert!((w[0] - 202.0).abs() < 0.5, "{w:?}"); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index aa222809d..d34a7fcef 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -67,25 +67,28 @@ pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option; /// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as /// the art it draws, and type and symbol instances as their art through `hook`. Past /// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further -/// instances add nothing. -pub fn member_leaves(n: &Node, hook: Option) -> Vec { +/// instances add nothing. The swatches and patterns that live-effect plug-ins painting their +/// pieces name are looked up in `doc` ([`shape_art`]). +pub fn member_leaves(doc: Option<&Document>, n: &Node, hook: Option) -> Vec { let mut out = vec![]; - leaves(n, hook, &SymbolNodes::default(), &mut out); + leaves(doc, n, hook, &SymbolNodes::default(), &mut out); out } /// [`member_leaves`], with the symbol art given so far counted in `nodes`. -fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) { +fn leaves(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) { if !n.visible { return; } // A blend, an envelope or an object with a warp takes part as the art it draws. - if let Some(art) = shape_art_in(n, hook, nodes) { + if let Some(art) = shape_art_in(doc, n, hook, nodes) { let first = out.len(); - leaves(&art, hook, nodes, out); - // Every path of a live object's, type's or symbol instance's art is drawn inside the - // object's opacity and blend mode. - if crate::needs_outline(n) { + leaves(doc, &art, hook, nodes, out); + // Every path of a live object's, type's or symbol instance's art, and every piece of a path + // whose live-effect plug-in paints its pieces, is drawn inside the object's opacity and + // blend mode. + let pieces = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && matches!(art.kind, NodeKind::Group { .. }); + if crate::needs_outline(n) || pieces { for l in out.iter_mut().skip(first) { within(l, n); } @@ -95,11 +98,11 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()), // A compound shape takes part as its area, styled like it. - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(doc, n, hook, nodes) { Some(art) => out.push(art), None => { for c in children { - leaves(c, hook, nodes, out); + leaves(doc, c, hook, nodes, out); } } }, @@ -114,7 +117,7 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve let art = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => { let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); - crate::reshape::outline_nested_text(&mut art, false); + crate::reshape::outline_nested_text(doc, &mut art, false); transparency_into_members(&mut art); art } @@ -122,7 +125,7 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve _ => art, }; let first = out.len(); - leaves(&art, None, nodes, out); + leaves(doc, &art, None, nodes, out); for l in out.iter_mut().skip(first) { within(l, n); } @@ -176,29 +179,37 @@ fn transparency_into_members(n: &mut Node) { /// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art /// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their /// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images, -/// for symbol instances without geometry effects, and for one without art from `hook`. -pub fn shape_art(n: &Node, hook: Option) -> Option { - shape_art_in(n, hook, &SymbolNodes::default()) +/// for symbol instances without geometry effects, and for one without art from `hook`. A +/// live-effect plug-in that paints its pieces makes a path, and each path of a group's or symbol's +/// art, a group of its pieces, whose swatches and patterns must be in `doc` (without one, pieces +/// that name one are refused and the path stays as it is). +pub fn shape_art(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + shape_art_in(doc, n, hook, &SymbolNodes::default()) } /// [`shape_art`], with the symbol art given so far counted in `nodes`. -fn shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { - let mut art = reshaped_art(n, hook, nodes)?; +fn shape_art_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let mut art = reshaped_art(doc, n, hook, nodes)?; transparency_into_members(&mut art); Some(art) } /// [`shape_art_in`] before the groups inside the art pass on their transparency. -fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { +fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { let fx = crate::reshape::geometry_effects(n); let outline = hook.unwrap_or(&crate::outline_text); match &n.kind { - NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)), - NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) { + // A path with a live-effect plug-in that paints its pieces: the group of its pieces, each + // with its own paints, as the renderer draws it. + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => { + let painted = crate::reshape::has_painted_effects(n).then(|| crate::reshape::reshape(doc, n, None)).flatten(); + Some(painted.unwrap_or_else(|| reshaped(n, n))) + } + NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(doc, n, hook, nodes) { Some(area) => Some(reshaped(&area, n)), None => { let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes); - crate::reshape::reshape_leaves(&mut m, &fx); + crate::reshape::reshape_leaves(&mut m, &fx, doc); without_geometry_effects(&mut m); Some(m) } @@ -209,8 +220,8 @@ fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Opt let h = hook?; let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); - crate::reshape::outline_nested_text(&mut art, false); - crate::reshape::reshape_leaves(&mut art, &fx); + crate::reshape::outline_nested_text(doc, &mut art, false); + crate::reshape::reshape_leaves(&mut art, &fx, doc); (art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate); art.appearance.effects = n.appearance.effects.clone(); without_geometry_effects(&mut art); @@ -270,12 +281,12 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp { /// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a /// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`], /// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)). -pub fn compound_shape_area(n: &Node, hook: Option) -> Option { - compound_shape_area_in(n, hook, &SymbolNodes::default()) +pub fn compound_shape_area(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + compound_shape_area_in(doc, n, hook, &SymbolNodes::default()) } /// [`compound_shape_area`], with the symbol art given so far counted in `nodes`. -fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { +fn compound_shape_area_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { if !n.is_compound_shape() { return None; } @@ -284,7 +295,7 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod let mut members: Vec<(po::BoolOp, usize)> = vec![]; for c in n.children()? { let before = all.len(); - leaves(c, hook, nodes, &mut all); + leaves(doc, c, hook, nodes, &mut all); if all.len() > before { members.push((shape_op(c.extras().shape_mode), all.len() - before)); } @@ -310,13 +321,13 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod /// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance, /// opacity and blend mode: how it takes part in a Pathfinder operation. -pub fn compound_shape_art(n: &Node, hook: Option) -> Option { - compound_shape_art_in(n, hook, &SymbolNodes::default()) +pub fn compound_shape_art(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + compound_shape_art_in(doc, n, hook, &SymbolNodes::default()) } /// [`compound_shape_art`], with the symbol art given so far counted in `nodes`. -fn compound_shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { - let area = compound_shape_area_in(n, hook, nodes)?; +fn compound_shape_art_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let area = compound_shape_area_in(doc, n, hook, nodes)?; let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() }); (art.opacity, art.blend) = (n.opacity, n.blend); Some(art) @@ -422,15 +433,16 @@ fn within_budget<'a>(leaves: impl IntoIterator) -> bool { /// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects. /// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`], /// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for -/// each effect) it returns the children as they are. -pub fn pathfinder_children(group: &Node, hook: Option) -> Option>> { +/// each effect) it returns the children as they are. The swatches and patterns that live-effect +/// plug-ins painting their pieces name are looked up in `doc` ([`shape_art`]). +pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option) -> Option>> { if !has_pathfinder(group) { return None; } let source: &[Arc] = group.children()?; let mut stack: Vec> = if group.is_compound_shape() { // The group's own appearance paints the area ([`evaluate_container`]), so the art has none. - match compound_shape_area(group, hook) { + match compound_shape_area(doc, group, hook) { Some(area) if area.is_empty() => vec![], Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))], None => return Some(source.to_vec()), @@ -442,7 +454,7 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option) -> Option, n: &Node) -> Vec<(PathData, FillRul NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)), NodeKind::Group { .. } if n.is_compound_shape() => { let hook = |c: &Node| crate::outline_object(doc, c); - match compound_shape_area(n, Some(&hook)) { + match compound_shape_area(doc, n, Some(&hook)) { Some(area) => out.push((area, FillRule::NonZero)), None => members(doc, n, out), } @@ -702,7 +714,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { let mut m = n.clone(); if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); - let result = pathfinder_children(n, Some(&hook)).unwrap_or_default(); + let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } @@ -711,7 +723,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - crate::reshape::reshape_leaves_in(&mut m, &fx, doc); + crate::reshape::reshape_leaves(&mut m, &fx, doc); } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index b67279bbb..d04a1f4ec 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -27,7 +27,7 @@ fn area(n: &Node) -> f64 { } fn overlap_cmyk(g: &Node) -> [f32; 4] { - let faces = pathfinder_children(g, None).unwrap(); + let faces = pathfinder_children(None, g, None).unwrap(); assert_eq!(faces.len(), 3); let overlap = faces .iter() @@ -61,7 +61,7 @@ fn trap_effect_adds_an_overprinting_strip_above_the_members() { let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 3, "both squares and one trap"); assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); let b = out[2].geometric_bounds().unwrap(); @@ -89,17 +89,17 @@ fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { #[test] fn shape_modes_follow_the_pathfinder_panel() { - let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); + let add = pathfinder_children(None, &group_with("pathfinder.add"), None).unwrap(); assert_eq!(add.len(), 1); assert!((area(&add[0]) - 150.0).abs() < 1e-3); // Add keeps the front object's paint, Subtract the back one's. assert_eq!(add[0].id, NodeId(2)); - let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap(); + let sub = pathfinder_children(None, &group_with("pathfinder.subtract"), None).unwrap(); assert_eq!(sub[0].id, NodeId(1)); assert!((area(&sub[0]) - 50.0).abs() < 1e-3); - let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap(); + let int = pathfinder_children(None, &group_with("pathfinder.intersect"), None).unwrap(); assert!((area(&int[0]) - 50.0).abs() < 1e-3); - let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap(); + let div = pathfinder_children(None, &group_with("pathfinder.divide"), None).unwrap(); assert_eq!(div.len(), 3); } @@ -112,7 +112,7 @@ fn divide_and_outline_draw_nothing_for_unpainted_members() { for effect in ["pathfinder.divide", "pathfinder.outline"] { let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]); g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}"); for n in out.iter().filter(|n| n.id == NodeId(2)) { assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}"); @@ -138,7 +138,7 @@ fn square_and(front: Option, effect: &str) -> Node { /// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. fn results(g: &Node) -> Vec> { - pathfinder_children(g, crate::text_outliner()).unwrap() + pathfinder_children(None, g, crate::text_outliner()).unwrap() } #[test] @@ -167,7 +167,7 @@ fn type_changes_the_result_of_every_pathfinder_effect() { assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); // Outline art nested one group deeper takes part as well. let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); - let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); + let sub = pathfinder_children(None, &square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); } @@ -243,7 +243,7 @@ fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() fn hidden_effect_or_plain_group_is_untouched() { let mut g = group_with("pathfinder.add"); g.appearance.effects[0].visible = false; - assert!(pathfinder_children(&g, None).is_none()); + assert!(pathfinder_children(None, &g, None).is_none()); assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK))); } @@ -328,7 +328,7 @@ fn pathfinder_past_its_budget_draws_the_source_art() { // n² crossings past the cap: no evaluation (#3591), the source art as it is. for n in [130, 800] { let g = crossing_bars(n, "pathfinder.divide"); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); let ids: Vec = out.iter().map(|c| c.id).collect(); let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); @@ -343,7 +343,7 @@ fn pathfinder_within_its_budget_still_divides() { // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. for n in [8, 128] { - let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); + let out = pathfinder_children(None, &crossing_bars(n, "pathfinder.divide"), None).unwrap(); assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); } } @@ -361,7 +361,7 @@ fn pathfinder_on_many_stacked_bars_still_runs() { .collect(); let mut g = Node::group(NodeId(10), children); g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 1); } @@ -382,9 +382,9 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] { let g = compound_shape(mode); assert!(has_pathfinder(&g), "{mode:?}"); - let area_path = compound_shape_area(&g, None).unwrap(); + let area_path = compound_shape_area(None, &g, None).unwrap(); assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}"); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 1, "{mode:?}"); assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it"); let art = evaluate_container(None, &g).unwrap(); @@ -401,7 +401,7 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { back.extras_mut().shape_mode = ShapeMode::Subtract; ch[0] = Arc::new(back); } - assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01); // Hidden members take no part; the mark on a clip group is ignored. let mut g = compound_shape(ShapeMode::Subtract); if let Some(ch) = g.children_mut() { @@ -409,10 +409,10 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { front.visible = false; ch[1] = Arc::new(front); } - assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01); let mut clip = compound_shape(ShapeMode::Add); clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true }; - assert!(!clip.is_compound_shape() && compound_shape_area(&clip, None).is_none()); + assert!(!clip.is_compound_shape() && compound_shape_area(None, &clip, None).is_none()); } /// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area, @@ -425,10 +425,10 @@ fn nested_compound_shapes_take_part_as_their_area() { let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]); outer.extras_mut().compound_shape = true; // (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high. - assert!((po::area(&compound_shape_area(&outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01); let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]); effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&effect, None).unwrap(); + let out = pathfinder_children(None, &effect, None).unwrap(); assert_eq!(out.len(), 1); assert!((area(&out[0]) - 50.0).abs() < 0.01); assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape"); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b251b15a6..1d85d9814 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -101,5 +101,6 @@ pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geomet #[cfg(test)] pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within}; pub use params::{ - default_params, has_geometry, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, scale_effect, upgrade_params, + default_params, has_geometry, has_painted_plugin_effect, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, + scale_effect, upgrade_params, }; diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index c4b6be4b7..03b335f01 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -43,7 +43,8 @@ fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { vectorcraft_text::register_envelope_ink(); let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); - f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) + let appearance = |n: &Node| bake_appearance(doc, n); + f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc }) } /// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index fbf22ff6b..75272c3e1 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -147,6 +147,14 @@ pub fn has_plugin_effect(n: &Node) -> bool { n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin)) } +/// Does `n` have a visible live-effect plug-in effect, its own or a fill's or stroke's, whose +/// plug-in paints the pieces it returns? What such an effect draws also depends on the document's +/// swatches and patterns, which its pieces may name. +pub fn has_painted_plugin_effect(n: &Node) -> bool { + let painted = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some_and(vectorcraft_plugins::effect::paints_output); + n.appearance.effects.iter().any(painted) || n.appearance.items.iter().any(|i| i.effects().iter().any(painted)) +} + /// The plug-in registry's revision, which changes whenever a plug-in is installed or removed. pub fn plugin_revision() -> u64 { vectorcraft_plugins::registry::revision() diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index a84d37bd0..af3d5d655 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -336,7 +336,7 @@ fn outline_art_with( _ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes), _ => return None, }; - outline_nested_text(&mut art, false); + outline_nested_text(doc, &mut art, false); art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; @@ -443,21 +443,22 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes /// its plain outlines ([`outline_text`]) with its visibility, which is what -/// [`crate::clip_outline`] clips by. -pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { +/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting +/// their pieces name are looked up in `doc`. +pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); let art = match &c.kind { NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - NodeKind::Text(_) => as_art(None, c, None), + NodeKind::Text(_) => as_art(doc, c, None), _ => None, }; if let Some(o) = art { *c = Arc::new(o); } else if c.children().is_some() { - outline_nested_text(Arc::make_mut(c), clipping); + outline_nested_text(doc, Arc::make_mut(c), clipping); } } } @@ -606,20 +607,16 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry /// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The /// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path -/// whose effect would pass what the others leave of it keeps its shape. -pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { - reshape_leaves_within(art, fx, Budget::EFFECTS); -} - -/// [`reshape_leaves`], with the swatches and patterns that painted live-effect plug-ins name looked -/// up in `doc` ([`reshape_leaves_apart`]). -pub(crate) fn reshape_leaves_in(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { - reshape_leaves_apart(art, fx, Budget::EFFECTS, false, doc); +/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns +/// that live-effect plug-ins painting their pieces name are looked up in `doc` +/// ([`reshape_leaves_apart`]). +pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { + reshape_leaves_within(art, fx, Budget::EFFECTS, doc); } /// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. -pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { - reshape_leaves_apart(art, fx, budget, false, None); +pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) { + reshape_leaves_apart(art, fx, budget, false, doc); } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -1247,7 +1244,7 @@ pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node { /// ([`evaluated_with`]). pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node { let mut art = evaluated_with(doc, n, outline, nodes); - outline_nested_text(&mut art, false); + outline_nested_text(doc, &mut art, false); art } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b2528818b..d2ad1e66c 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -434,7 +434,7 @@ mod tests { let expanded = expand_revolve_art(&n).unwrap(); let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len(); assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed"); - assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface"); + assert_eq!(crate::bake_appearance(None, &n), Some(live.clone()), "other baking retains the full surface"); n.appearance.effects[0].params["expandVisibleOnly"] = json!(false); assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting"); assert_eq!(expand_revolve_art(&n), Some(live)); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index a9997bb5d..e5f42b4e8 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -325,7 +325,7 @@ fn export_and_expand_bake_faces_with_their_own_ids() { let mut n = square_node(); n.id = d.alloc_id(); n.appearance.effects.push(fx(id, json!({"bevel": "rounded"}))); - assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}"); + assert_eq!(crate::bake_appearance(None, &n), solid_art(&n), "{id}"); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); let mut ids = std::collections::HashSet::new(); @@ -351,7 +351,7 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() { let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35}); n.appearance.effects.push(fx(INFLATE, params)); let art = solid_art(&n).unwrap(); - assert_eq!(crate::bake_appearance(&n), Some(art.clone())); + assert_eq!(crate::bake_appearance(None, &n), Some(art.clone())); assert!(faces(&art).len() > 20, "{} faces", faces(&art).len()); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ea3843f0e..f9ca9aa1f 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -394,7 +394,7 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default())); let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect()); let rough = fx("distort.roughen", json!({"size": 50, "detail": 100})); - crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }); + crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }, None); let mut total = 0; let mut reshaped = 0; for c in art.children().into_iter().flatten() { @@ -473,7 +473,7 @@ fn reshape_leaves_within_optimizes_transform_before_terminal_offset_path() { let offset = fx("path.offsetPath", json!({"offset": 4.0, "joins": "round"})); let start = std::time::Instant::now(); - crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS, None); let elapsed = start.elapsed(); assert!(elapsed < std::time::Duration::from_secs(2)); @@ -500,7 +500,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let mut art_evenodd = Node::group(id, vec![Arc::new(leaf)]); let transform = fx("distort.transform", json!({"moveH": 2.0, "moveV": 2.0, "copies": 2})); let offset = fx("path.offsetPath", json!({"offset": 4.0})); - crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS, None); let p = art_evenodd.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "EvenOdd does not swap; copies union into 1 outer subpath"); @@ -508,7 +508,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let leaf_round = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default())); let mut art_round = Node::group(id, vec![leaf_round]); let round_corners = fx("stylize.roundCorners", json!({"radius": 2.0})); - crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS, None); let p = art_round.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "Non-terminal Offset Path does not swap; copies union into 1 outer subpath"); @@ -516,7 +516,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let leaf_miter = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default())); let mut art_miter = Node::group(id, vec![leaf_miter]); let offset_miter = fx("path.offsetPath", json!({"offset": 4.0, "joins": "miter"})); - crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS, None); let p = art_miter.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "Miter join does not swap; copies union into 1 outer subpath"); } diff --git a/crates/engine/src/cmd/compoundshape.rs b/crates/engine/src/cmd/compoundshape.rs index 52c19a9a5..bc93b399a 100644 --- a/crates/engine/src/cmd/compoundshape.rs +++ b/crates/engine/src/cmd/compoundshape.rs @@ -189,7 +189,7 @@ fn make(s: &mut Session, p: &Value) -> Result { /// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or /// a warped group, also takes that object's opacity and blend mode. fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option