From d202910faafd23a593d85c896d78459fb6ee227e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 17:45:07 -0700 Subject: [PATCH 01/82] 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/82] 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/82] 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/82] 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/82] 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 5018ff319f108976a35673834fc9a30e01448175 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 11:59:56 -0700 Subject: [PATCH 06/82] 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 8a0b7969455972f0af377e9de2c16a1afd99df17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 23:05:23 -0700 Subject: [PATCH 07/82] 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 e11e7a39d9d7b512fe97ee843f3c3da0bb741d46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:02:03 -0700 Subject: [PATCH 08/82] 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 efd7f3e97a27d22c82d6d89144b99bc619012599 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 03:38:35 -0700 Subject: [PATCH 09/82] Blend: a pending pick starts over when the document's history changes The Blend tool kept its first pick, its clicked anchor and the blend it was adding to through edits made outside the tool. Picking A, recoloring other art and clicking B made a blend of A and B; undoing A's creation left the next click doing nothing. History::step names the current step by the serial numbers of the newest undo step and the next redo step. The session keeps the active document's uid and step from the tool's last pointer event and calls the new Tool::history_moved at the next pointer event, or before drawing overlays, when they differ. The tool's own edits are recorded after its actions; a selection tool lent by Cmd is not told. BlendTool clears its pick and chain there, as Escape does. A selection change keeps them. --- crates/engine/src/history.rs | 28 +++++++++- crates/engine/src/lib.rs | 4 ++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/blendopts.rs | 74 +++++++++++++++++++++++++- crates/engine/src/tooling.rs | 28 ++++++++++ crates/tools/src/lib.rs | 4 ++ crates/tools/src/meshblend.rs | 5 ++ 7 files changed, 141 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index ec53acd5b..ee6adccc4 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -19,6 +19,14 @@ pub struct HistoryEntry { next: usize, /// The template and name `label` was made from ([`Session::edit_named`]). pub(crate) named: Option, + /// A number no other step takes ([`History::step`]). + serial: u64, +} + +/// The next [`HistoryEntry::serial`]: 1 for the first step made, so 0 never names a step. +fn next_serial() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } /// A step name made by filling the `{name}` placeholder of a template with a name @@ -45,7 +53,7 @@ impl HistoryEntry { pub(crate) fn kept(label: String, doc: Arc, selection: Arc, next: &Document) -> Self { doc.drop_lookup_cache(); let (bytes, next_bytes) = doc.retained_bytes_each(next); - HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None } + HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None, serial: next_serial() } } /// The template and name the step's label was made from, while the label is still the one @@ -66,7 +74,16 @@ impl HistoryEntry { return Self::kept(label, doc, selection, now); } doc.drop_lookup_cache(); - HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize, named: None } + HistoryEntry { + label, + doc, + selection, + bytes: costs.next_bytes, + next_bytes: costs.bytes, + next: Arc::as_ptr(now) as usize, + named: None, + serial: next_serial(), + } } /// The figures [`HistoryEntry::turned`] reuses. @@ -111,6 +128,13 @@ impl History { self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum() } + /// Which step the document is at: the newest undo step and the next redo step, by numbers no + /// other step takes (0 for none). Every new step, Undo and Redo changes it; an edit merged into + /// the newest step (an undo group) and a selection change do not. + pub fn step(&self) -> (u64, u64) { + (self.undo.last().map_or(0, |e| e.serial), self.redo.last().map_or(0, |e| e.serial)) + } + /// Let the oldest steps go while over the step limit or the memory budget: undo steps first /// (the oldest), then redo steps (the furthest ahead). pub fn trim(&mut self) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7f32ca994..226c391d3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -60,6 +60,10 @@ pub struct Session { /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes /// back as it was at the release ([`Session::pointer`]). pub(crate) lender: Option>, + /// The active document (its uid) and its [`History::step`] when the tool last handled a pointer + /// event or drew its overlays: when they differ at the next one, the tool is told first + /// ([`vectorcraft_tools::Tool::history_moved`]). None until the first. + pub(crate) tool_step: Option<(u64, (u64, u64))>, /// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 89d846330..c073369aa 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -31,6 +31,7 @@ impl Session { journal: Journal::default(), tool: vectorcraft_tools::create("selection"), lender: None, + tool_step: None, last_selection_tool: None, last_view: ViewInfo::default(), preview_memo: Default::default(), diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 97f356078..ec05893f8 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -1,6 +1,6 @@ //! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool: //! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and -//! Escape dropping the first pick. +//! Escape, or an edit made outside the tool, dropping the first pick. use serde_json::{Value, json}; use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; @@ -167,6 +167,78 @@ fn escape_drops_the_blend_tools_first_pick() { assert_eq!(spec(&s, g).0, vec![b, c], "a was not added"); } +#[test] +fn an_edit_or_undo_made_outside_the_blend_tool_drops_its_first_pick() { + for undo in [false, true] { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + // Undo takes away c, the object picked first; an edit recolors c, which is not picked. + click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0); + assert!(!s.overlays(ViewInfo::default()).is_empty()); + if undo { + s.execute("edit.undo", &json!({})).unwrap(); + } else { + s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); + } + assert!(s.overlays(ViewInfo::default()).is_empty(), "undo {undo}: the pick is not marked"); + let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len()); + click(&mut s, 220.0, 20.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![b], "undo {undo}: b is picked first"); + assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "undo {undo}: the click changes no art"); + click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "undo {undo}"); + } +} + +#[test] +fn removing_the_blend_the_blend_tool_adds_to_ends_adding() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + let (d, e) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0)); + let other = id_of(&s.execute("object.blend.make", &json!({"ids": [d.0, e.0]})).unwrap()); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + click(&mut s, 20.0, 20.0); + click(&mut s, 220.0, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + s.execute("edit.clear", &json!({"ids": [g.0]})).unwrap(); + sel(&mut s, &[other]); + click(&mut s, 420.0, 20.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![c], "c is picked first"); + assert_eq!(spec(&s, other).0, vec![d, e], "c is not added to the selected blend"); + assert!([a, b, g].iter().all(|id| s.doc().unwrap().doc.node(*id).is_none()), "the blend went with its keys"); +} + +#[test] +fn a_selection_change_keeps_the_blend_tools_first_pick() { + for drag in [false, true] { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + let view = ViewInfo::default(); + s.select_tool("blend", view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 20.0, 20.0), view).unwrap(); + sel(&mut s, &[c]); + // Cmd lends the selection tool: a click on c selects it, a drag moves it (an edit). + let cmd = Mods { cmd: true, ..Mods::default() }; + let to = if drag { 440.0 } else { 420.0 }; + s.pointer(&PointerEvent::new(PointerKind::Down, 420.0, 20.0).with_mods(cmd), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to, 20.0).with_mods(cmd), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to, 20.0).with_mods(cmd), view).unwrap(); + assert_eq!(s.tool_id(), "blend"); + assert_eq!(s.overlays(view).is_empty(), drag, "drag {drag}"); + s.pointer(&PointerEvent::new(PointerKind::Down, 220.0, 20.0), view).unwrap(); + if drag { + assert_eq!(s.doc().unwrap().selection.objects, vec![b], "the drag dropped the pick"); + } else { + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, vec![a, b]); + } + } +} + #[test] fn start_points_round_trip_and_old_blends_load() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 81f8616a8..94d2d6742 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -328,6 +328,25 @@ impl Session { self.tool_panic.take().map_or(Ok(()), Err) } + /// The active document (its uid) and its [`crate::History::step`]. + fn history_step(&self) -> Option<(u64, (u64, u64))> { + self.active().map(|d| (d.uid, d.history.step())) + } + + /// Tell the active tool when the history moved, or another document became active, since it + /// last handled a pointer event or was last told ([`vectorcraft_tools::Tool::history_moved`]). + /// A lent selection tool is not told. + fn tell_tool_history_moved(&mut self) { + if self.lender.is_some() { + return; + } + let now = self.history_step(); + if self.tool_step.is_some_and(|seen| Some(seen) != now) { + self.tool.history_moved(); + } + self.tool_step = now; + } + /// Feed a pointer event to the active tool. Returns requests for the UI. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { self.last_view = view; @@ -341,7 +360,9 @@ impl Session { return r; } self.leave_draw_inside(ev, view)?; + self.tell_tool_history_moved(); let ev = &self.lend_selection_tool(ev); + let lent = self.lender.is_some(); let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); let r = self.take_tool_panic().and_then(|()| { // A gesture may change options (Alt-drag sizes a Liquify brush): keep them. @@ -350,6 +371,11 @@ impl Session { } self.apply_actions(acts) }); + // The tool's own edits do not move the history for it. A selection tool that Cmd lends + // edits for the lender, which hears of the edit at its next event. + if !lent { + self.tool_step = self.history_step(); + } if ev.kind == PointerKind::Up { self.give_back_tool(view)?; } @@ -553,6 +579,8 @@ impl Session { } pub fn overlays(&mut self, view: ViewInfo) -> Vec { + // The Blend tool's first pick is not marked after an edit that dropped it. + self.tell_tool_history_moved(); let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); // The Type tools draw the ports of selected type themselves. if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index e55bb4069..686e00408 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -224,6 +224,10 @@ pub trait Tool: Send { fn after_command(&mut self, _cx: &ToolContext) -> Vec { vec![] } + /// The undo history moved outside the tool (an edit, Undo or Redo the tool did not make), or + /// another document became active. The Blend tool drops its first pick and stops adding to the + /// blend it just made. + fn history_moved(&mut self) {} /// Time passes while the pointer button is held (`dt` seconds since the last tick, whether or /// not the pointer moved): tools that keep working while the brush holds still (Twirl, Pucker, /// Bloat) act on it. The host supplies the time, so tests and agents drive it exactly. diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index d6a46faf0..e881d54c4 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -148,6 +148,11 @@ impl Tool for BlendTool { (self.first, self.chained) = (None, false); vec![] } + /// An edit, Undo or Redo made outside the tool drops the object picked first and stops adding to + /// the blend just made, so the next click picks a new first object. + fn history_moved(&mut self) { + (self.first, self.chained) = (None, false); + } } #[derive(Default)] From 5a342685de8a2cdce0e0da982dcd8e259f68ee4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 12:49:10 -0700 Subject: [PATCH 10/82] 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 11/82] 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 12/82] 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 13/82] 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 14/82] 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 15/82] 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 16/82] 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 e1440a3a9012779be7f40cfd166349cbfdc61372 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 21:14:57 -0700 Subject: [PATCH 17/82] Paragraph: Kinsoku Shori Type chooses Push In First, Push Out First or Push Out Only The Paragraph panel menu (with the East Asian options) gains Kinsoku Shori Type, and text.setFormat takes kinsokuType. Where the Kinsoku Set keeps a character off the start of the next line or the end of this one, Push In First sets it on the line and closes the line's gaps next to CJK characters to fit, each by at most a tenth of an em of the character before it; a line that cannot close up that much, or that holds a tab, pushes out. With Optical Margin Alignment or Roman Hanging Punctuation on, the gaps that hanging punctuation keeps open are not counted. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only, the default, is the line breaking area type had before, so existing documents lay out unchanged. The setting does nothing with Kinsoku Set None, is saved only when it is not Push Out Only, and paragraph styles carry it. Reset Panel sets it back to Push Out Only. Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md. --- crates/doc/src/lib.rs | 6 +- crates/doc/src/text.rs | 4 +- crates/doc/src/text/para_style.rs | 28 +++++ crates/engine/src/cmd/panelcmds.rs | 15 ++- crates/engine/src/cmd/textstyles.rs | 1 + crates/engine/src/cmd/typecmd/area_tests.rs | 37 ++++++ crates/text/src/layout.rs | 4 +- crates/text/src/layout/breaking.rs | 124 ++++++++++++++++++- crates/text/src/layout/flow.rs | 67 +++++++--- crates/text/src/layout/wrapping_tests.rs | 88 +++++++++++-- crates/text/src/tests/vertical.rs | 51 ++++++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/paragraph.rs | 35 +++++- crates/ui-egui/src/panels/paragraph/tests.rs | 26 ++++ docs/mcp.md | 21 ++++ docs/target-app-parity.md | 1 + docs/type-parity.md | 1 + 42 files changed, 574 insertions(+), 39 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 26e2bfebf..0ff13cd31 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -108,9 +108,9 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, - JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, - RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, - WrapShape, + JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, + RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, + VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c1a36e058..664247a30 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -17,8 +17,8 @@ pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, use char_style::yes; pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics}; pub use para_style::{ - Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, - ParaStyle, SingleWordJustify, + Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, + Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef}; pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop}; diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index a257e2e0a..d78363681 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -233,6 +233,9 @@ pub struct ParaStyle { /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a /// line. pub kinsoku: Kinsoku, + /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start + /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. + pub kinsoku_type: KinsokuShoriType, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -315,6 +318,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku: Kinsoku, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kinsoku_type: KinsokuShoriType, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -346,6 +351,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, .. @@ -370,6 +376,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, } @@ -398,6 +405,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, .. @@ -421,6 +429,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + kinsoku_type, roman_hanging, kashida, } @@ -485,6 +494,25 @@ pub enum Kinsoku { Soft, } +/// How a line keeps the characters [`Kinsoku`] rules out of its start and end (Kinsoku Shori +/// Type). Pushing in sets the characters that would start the next line (or that an opening +/// bracket ending this line would pull with it) on this line, with the line's gaps next to CJK +/// characters closed to make room, each by the same share of the em of the character before it +/// and by at most a tenth of that em; pushing out moves the character before them to the next +/// line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KinsokuShoriType { + /// Push in when the line can close up enough, else push out. + PushInFirst, + /// Push out, unless that would leave the line more than an em short (of the character before + /// the break): then push in when the line can close up enough. + PushOutFirst, + /// Always push out; never push in. + #[default] + PushOutOnly, +} + /// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。) /// stands outside the line's measure, in the space its punctuation spacing gives it (half width /// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 7f504dcc4..83f4f72eb 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, always moves it; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -364,6 +364,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "singleWordJustification", "mojikumi", "kinsoku", + "kinsokuType", "direction", "leadingModel", "charAlign", @@ -414,6 +415,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`kinsoku` must be \"none\", \"hard\" or \"soft\"")), }), }; + let kinsoku_type = match p.get("kinsokuType") { + None => None, + Some(v) => Some(match v.as_str() { + Some("pushInFirst") => vectorcraft_doc::KinsokuShoriType::PushInFirst, + Some("pushOutFirst") => vectorcraft_doc::KinsokuShoriType::PushOutFirst, + Some("pushOutOnly") => vectorcraft_doc::KinsokuShoriType::PushOutOnly, + _ => return Err(bad(C, "`kinsokuType` must be \"pushInFirst\", \"pushOutFirst\" or \"pushOutOnly\"")), + }), + }; let direction = match p.get("direction") { None => None, Some(v) => Some(match v.as_str() { @@ -635,6 +645,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = kinsoku { para.kinsoku = v; } + if let Some(v) = kinsoku_type { + para.kinsoku_type = v; + } if let Some(v) = direction { para.direction = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 5995c490d..46c0038dd 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -201,6 +201,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("leading_model", json!(v.leading_model)), ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), + ("kinsoku_type", json!(v.kinsoku_type)), ("roman_hanging", json!(v.roman_hanging)), ("kashida", json!(v.kashida)), ] { diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 65febdf0b..ab74c1708 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -98,6 +98,43 @@ fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() { assert_eq!(old.kinsoku, Kinsoku::Hard); } +/// Paragraph panel menu › Kinsoku Shori Type: `text.setFormat {kinsokuType}` sets it per +/// paragraph in one undo step, refuses unknown values, saves it only when it is not Push Out Only +/// (new type's and older documents' type), reopens with the file, and paragraph styles carry it. +#[test] +fn kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only() { + use vectorcraft_doc::KinsokuShoriType; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あ」\nい", "area": {"width": 100, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushOutOnly); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kinsoku_type").is_none()); + s.execute("text.setFormat", &json!({"id": id, "start": 0, "end": 1, "kinsokuType": "pushInFirst"})).unwrap(); + assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kinsoku_type"], "pushInFirst"); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).kinsoku_type, back.para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert!(s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushIn"})).is_err()); + assert_eq!(text(&s).para_at(0).kinsoku_type, KinsokuShoriType::PushInFirst, "an unknown value changes nothing"); + s.execute("text.setFormat", &json!({"id": id, "kinsokuType": "pushOutFirst"})).unwrap(); + assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((text(&s).para_at(0).kinsoku_type, text(&s).para_at(1).kinsoku_type), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Pushed in", "attrs": {"kinsoku_type": "pushOutFirst"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Pushed in", "id": id})).unwrap(); + assert_eq!(text(&s).para_at(1).kinsoku_type, KinsokuShoriType::PushOutFirst); + let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap(); + assert_eq!(old.kinsoku_type, KinsokuShoriType::PushOutOnly); +} + #[test] fn burasagari_is_set_per_paragraph_and_saved_only_when_on() { use vectorcraft_doc::Burasagari; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f217eaa3a..2a63aa80f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -6,8 +6,8 @@ use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ - Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, - RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, + Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, + PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index b83640d02..05cb35902 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,7 +1,8 @@ use super::region::Region; +use super::vertical::glyph_em; use super::{ - Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, LeadingModel, SGlyph, hyphen_glyph, - hyphen_points_with, no_line_end, no_line_start, soft_line_start, + Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, + hyphen_glyph, hyphen_points_with, is_cjk, no_line_end, no_line_start, soft_line_start, }; /// Vertical metrics of a line: (ascent, descent, leading, cap height, x height). @@ -268,7 +269,10 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when /// the line may hyphenate a word; a line whose text before the overflowing word already ends /// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop -/// (`tabs`), with the line's content starting `x_start` from the tab origin. +/// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone +/// keeps the line from breaking before the character that overflows, `kinsoku_type` decides +/// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical +/// Margin Alignment or Roman Hanging Punctuation is on. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -280,6 +284,8 @@ pub(super) fn break_line( hyphenate: Option<(&HyphenationOptions, f64)>, burasagari: Burasagari, kinsoku: Kinsoku, + kinsoku_type: KinsokuShoriType, + hanging: bool, ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -329,6 +335,9 @@ pub(super) fn break_line( } return (end, false); } + if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging) { + return (end, false); + } break; } x += cluster_width; @@ -491,6 +500,115 @@ fn kinsoku_between(before: char, after: Option, kinsoku: Kinsoku) -> bool } } +/// The share of an em by which a push-in ([`push_in`]) may close each gap next to a CJK character +/// on the line, measured on the character before the gap. +pub(super) const PUSH_IN_SQUEEZE: f64 = 0.1; + +/// The most glyphs after the overflowing cluster a push-in ([`push_in`]) takes onto the line; a +/// longer run of characters kinsoku keeps together pushes out. +const PUSH_IN_RUN: usize = 16; + +/// Kinsoku Shori Type: the end of the line from `i` that overflows `width` at the cluster +/// `j..cluster_end` (the line is `x` wide through it) when the line pushes in, or None when it +/// pushes out. A line pushes in only where kinsoku rules out the break before `j`: a character +/// that may not end a line comes before it (an opening bracket or quotation mark, Latin ones +/// included), or one that may not start a line comes at `j`. It then takes the characters up to +/// the next break kinsoku allows (not at a soft hyphen), and the spaces after it, when closing +/// its gaps next to CJK characters, each by up to [`PUSH_IN_SQUEEZE`] of an em of the character +/// before it, makes room for them. Push In First tries this before it pushes out. Push Out First +/// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the +/// line short by more than an em of the character before `j`. Push Out Only never pushes in, and +/// neither does a line holding a tab, nor one where the characters taken in reach a space after a +/// character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging +/// Punctuation), the gaps that hanging punctuation keeps open are not counted. +#[allow(clippy::too_many_arguments)] +fn push_in( + g: &[SGlyph], + i: usize, + j: usize, + cluster_end: usize, + x: f64, + width: f64, + pushed_out: Option, + kinsoku: Kinsoku, + kinsoku_type: KinsokuShoriType, + hanging: bool, +) -> Option { + if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { + return None; + } + let before = g.get(j.checked_sub(1)?)?; + if before.len == 0 || g.get(j)?.no_break || splits_no_break(g, j) || kinsoku_allows(g, j - 1, kinsoku) { + return None; + } + let allowed = |k: usize| { + k >= g.len() + || (g[k].byte != g[k - 1].byte + && kinsoku_allows(g, k - 1, kinsoku) + && (g[k].is_space() || (g[k - 1].break_after() && !g[k - 1].is_soft_hyphen()))) + }; + let (mut end, mut x) = (cluster_end, x); + while !allowed(end) { + let gl = &g[end]; + // A space here follows a character kinsoku keeps off a line's end: the line pushes out. + if gl.is_space() || gl.ch == '\t' || gl.ch == '\u{2028}' || gl.no_break || end - cluster_end >= PUSH_IN_RUN { + return None; + } + x += gl.adv; + end += 1; + } + // A tab's width depends on where it falls; a line with one (other than a list marker's) + // pushes out. + if g[i..end].iter().any(|h| h.ch == '\t' && h.len > 0) { + return None; + } + // With `hanging`, punctuation keeps the gap after a mark that may hang at the line's start, + // and the gap before one that may hang at its end ([`optical_hangs`]): those gaps are not + // counted. + let mut last = end; + while last > i && g[last - 1].is_space() { + last -= 1; + } + let punct = |k: usize| g.get(k).is_some_and(optical_punct); + let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch)); + let may_hang = |a: usize| { + hanging + && ((a == i && punct(i)) + || (a == i + 1 && punct(i) && quote(i + 1)) + || (a + 2 == last && punct(last - 1)) + || (a + 3 == last && punct(last - 2) && quote(last - 1))) + }; + let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * glyph_em(&g[a])).sum(); + if x - width > room + EPS { + return None; + } + if kinsoku_type == KinsokuShoriType::PushOutFirst { + let mut kept = pushed_out.filter(|&b| b > i).unwrap_or(i); + while kept > i && g[kept - 1].is_space() { + kept -= 1; + } + let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); + if short <= glyph_em(before) + EPS { + return None; + } + } + while g.get(end).is_some_and(SGlyph::is_space) { + end += 1; + } + if g.get(end).is_some_and(|h| h.ch == '\u{2028}') { + end += 1; + } + Some(end) +} + +/// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them +/// CJK, outside a tate-chu-yoko block and a list marker, and not next to a space, a tab or an +/// inline graphic before it. +fn closes(g: &[SGlyph], a: usize) -> bool { + let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; + p.len > 0 && p.inline.is_none() && !p.is_space() && !n.is_space() && p.ch != '\t' && !n.continues_tcy() && (is_cjk(p.ch) || is_cjk(n.ch)) +} + #[cfg(test)] #[test] fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() { diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 03c2eb3c4..c28508751 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -1,16 +1,22 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{ - Metrics, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, roman_share, - set_width, tab_advance, word_width, + Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, + roman_share, set_width, tab_advance, word_width, }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; use super::{ - Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, LeadingModel, LineFit, LineInfo, ListMarker, Mojikumi, ParaStyle, - Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, is_cjk, - is_word_space, kashida, mark_word_ends, style_metrics, + Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo, + ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, + fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; +/// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First, +/// with kinsoku on)? +fn pushes_in(para: &ParaStyle) -> bool { + para.kinsoku != Kinsoku::None && para.kinsoku_type != KinsokuShoriType::PushOutOnly +} + /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; @@ -32,6 +38,8 @@ fn compose_para( // A paragraph whose commas or full stops may hang is composed line by line (as the Japanese // single-line composer does). let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); + // So is a paragraph that may push characters kinsoku keeps off a line's ends in onto a line. + let may_push_in = pushes_in(para) && sg.iter().any(|g| no_line_start(g.ch) || no_line_end(g.ch)); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). // The tab after a list marker always starts the first line and has a fixed width. @@ -42,6 +50,7 @@ fn compose_para( || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang + || may_push_in || has_tab || has_soft_break { @@ -283,6 +292,8 @@ struct PlacementContext<'a> { zone: f64, index: usize, para_line: usize, + /// Optical Margin Alignment or Roman Hanging Punctuation is on. + hanging: bool, } struct LinePosition { @@ -291,13 +302,16 @@ struct LinePosition { x1: f64, end: usize, hyph: bool, + /// Kinsoku Shori Type pushed characters in onto the line ([`break_line`]). + pushed_in: bool, m: Metrics, ind_l: f64, ind_r: f64, } fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option { - let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line } = context; + let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } = + context; let n = sg.len(); let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm }; let first_line = *para_line == 0 && !*continued; @@ -326,10 +340,16 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC }; let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((¶.hyphenation, *zone)); - let (end, hyph) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { - Some(&(end, hyph)) if end > *i => (end, hyph), - _ if *i < n => break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku), - _ => (n, false), + let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { + Some(&(end, hyph)) if end > *i => (end, hyph, false), + _ if *i < n => { + let line = |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging); + let (end, hyph) = line(para.kinsoku_type); + // The line pushed characters in where it ends after the line that pushes out. + let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; + (end, hyph, pushed_in) + } + _ => (n, false, false), }; let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm); baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { @@ -351,7 +371,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC pen.queued.clear(); continue; } - return Some(LinePosition { baseline, x0, x1, end, hyph, m, ind_l, ind_r }); + return Some(LinePosition { baseline, x0, x1, end, hyph, pushed_in, m, ind_l, ind_r }); } } @@ -379,6 +399,10 @@ struct LineJustification { per_space: f64, per_gap: f64, per_cjk: f64, + /// Kinsoku Shori Type: the share of an em (of the glyph before it, at most + /// [`PUSH_IN_SQUEEZE`]) each gap next to a CJK character closes by on a line that pushed + /// characters in; 0 on other lines. + squeeze: f64, fit: LineFit, start_x: f64, after: Vec>, @@ -413,6 +437,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut zone: paragraph.zone, index, para_line: line_in_paragraph, + hanging: t.optical_margin || paragraph.para.roman_hanging, }; let Some(position) = place_flow_line(cx, pen, &mut placement) else { return false }; hyphen_run = if position.hyph && position.end > index { hyphen_run + 1 } else { 0 }; @@ -637,7 +662,17 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); - if justify && (room - width).abs() > EPS { + let mut squeeze = 0.0; + // Kinsoku Shori Type: a line of area type that pushed characters in is wider than its + // measure, and closes its gaps next to CJK characters to fit, however it is aligned: each by + // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. + let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; + if pushed_in { + let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| glyph_em(&sg[j])).sum(); + if ems > EPS { + squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); + } + } else if justify && (room - width).abs() > EPS { if cjk_gaps > 0 && room > width { // Spread over the CJK gaps and the word spaces alike. per_space = (room - width) / (cjk_gaps + spaces) as f64; @@ -665,7 +700,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' } } let position = &line.position; - let start_x = if justify { + let start_x = if justify || pushed_in { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { match align { @@ -680,7 +715,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, fit, start_x, after, kashidas } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas } } fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { @@ -736,9 +771,9 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData } if glyph.is_space() { advance += justification.per_space; - } else if justification.per_cjk != 0.0 { + } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk; + advance += justification.per_cjk + justification.squeeze * glyph_em(glyph); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 360c386ea..e6b6ad08f 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -53,7 +53,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -66,11 +66,12 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); - let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard); + let (second_end, hyphenated) = + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -125,7 +126,7 @@ fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); - let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -133,7 +134,7 @@ fn no_break_keeps_marked_characters_on_one_line() { for g in &mut marked { g.no_break = true; } - let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -142,11 +143,84 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); } + +/// Synthetic full-width characters `text`, each 10 pt wide on a 10 pt em. +fn full_width(text: &str) -> Vec { + let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + for g in &mut glyphs { + g.sy = 10.0 / g.face.units_per_em(); + } + glyphs +} + +/// Kinsoku Shori Type, in a measure of 15 full-width characters: a closing bracket that would +/// start the next line is pushed in onto the line with Push In First, and the character before +/// it is pushed out with it with Push Out First and Push Out Only. Push Out First pushes in when +/// pushing out would leave the line two characters short (an opening bracket goes too). A line +/// that cannot close up enough pushes out, and with Kinsoku None the types do nothing. +#[test] +fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { + use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; + let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging).0 + }; + let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); + let text = "一二三四五六七八九十一二三四五」六"; + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushInFirst), 16, "」 is pushed in"); + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutFirst), 14, "五 is pushed out with 」"); + assert_eq!(end(text, 150.0, Kinsoku::Hard, PushOutOnly), 14, "五 is pushed out with 」"); + assert_eq!(end(text, 150.0, Kinsoku::Soft, PushInFirst), 16, "Soft keeps 」 off the line start too"); + assert_eq!(end(text, 150.0, Kinsoku::None, PushInFirst), 15, "no kinsoku: 」 starts the next line"); + + let bracketed = "一二三四五六七八九十一二三「四」六"; + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutFirst), 16, "pushing out 「四 would leave two ems"); + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushInFirst), 16); + assert_eq!(end(bracketed, 150.0, Kinsoku::Hard, PushOutOnly), 13); + + // Latin brackets and quotation marks are pushed in too: an opening one that would end the + // line, and a closing one after a Latin letter that would start the next. + for opening in ["一二三四五六七八九十一二三四(五六", "一二三四五六七八九十一二三四\u{201c}五六"] { + assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushInFirst), 16, "{opening}: the bracket and 五 are pushed in"); + assert_eq!(end(opening, 150.0, Kinsoku::Hard, PushOutOnly), 14, "{opening}: the bracket is pushed out with 五"); + } + let latin = "一二三四五六七八九十一二三四a)"; + assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushInFirst), 16, ") is pushed in"); + assert_eq!(end(latin, 150.0, Kinsoku::Hard, PushOutOnly), 14, "a is pushed out with )"); + // A push-in does not end at a soft hyphen: taking `b` and the soft hyphen in would fit, the + // whole word `bc` does not, so the line pushes out. + let shy = format!("{}a)b\u{ad}c", "一".repeat(31)); + assert_eq!(end(­, 320.0, Kinsoku::Hard, PushInFirst), 31); + assert_eq!(end(­, 320.0, Kinsoku::Hard, PushOutOnly), 31); + // Nor at a space after an opening bracket, which would leave the bracket ending the line. + let spaced = format!("{}「「 A", "一".repeat(14)); + assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushInFirst), 14); + assert_eq!(end(&spaced, 150.0, Kinsoku::Hard, PushOutOnly), 14); + // With hanging punctuation on, the gap before a full stop that may hang at the line's end is + // not counted: the other nine gaps close by 9 pt, short of the 10 pt the full stop needs. With + // it off, all ten gaps close and the full stop is pushed in. + let stop = format!("{}五.", "一".repeat(9)); + assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushInFirst, true), 9); + assert_eq!(hung(&stop, 100.0, Kinsoku::Hard, PushOutOnly, true), 9); + assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushInFirst), 11); + assert_eq!(end(&stop, 100.0, Kinsoku::Hard, PushOutOnly), 9); + + // Two gaps close by at most 2 pt, not the 5 pt 」 needs. + let short = "一二」三"; + assert_eq!(end(short, 25.0, Kinsoku::Hard, PushInFirst), 1, "too little to close up: 二 is pushed out"); + + // A line holding a tab pushes out. Here the tab reaches the default stop at 36 pt, so pushing + // out leaves the line one em short, though the tab glyph's own advance is 10 pt. + let tabbed = "一\t二三四五六七八九十一二」六"; + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); + assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); +} diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 1010ed84a..334a91105 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -699,6 +699,57 @@ fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() { } } +/// Kinsoku Shori Type: in a measure of fifteen ems, the closing bracket after the fifteenth +/// character ends the first line with Push In First, which closes the line's gaps to keep it inside +/// the frame whatever its alignment; Push Out Only moves the fifteenth character to the next line +/// with the bracket. Horizontal and vertical. Needs a font with full-width Japanese (skipped +/// without one). +#[test] +fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { + use vectorcraft_doc::KinsokuShoriType; + let text = "一二三四五六七八九十一二三四五」六"; + let lay = |ty: KinsokuShoriType, justify: Justify, vertical_type: bool| { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.vertical = vertical_type; + t.para.kinsoku_type = ty; + t.para.justify = justify; + let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 300.0) } else { Rect::new(0.0, 0.0, 300.0, 200.0) }; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + layout(FontDb::global(), &t) + }; + if (lay(KinsokuShoriType::PushOutOnly, Justify::Left, false).glyphs[0].advance - 20.0).abs() > 2.0 { + return; // no font with full-width Japanese here + } + for vertical_type in [false, true] { + let lines = |ty| lay(ty, Justify::Left, vertical_type).glyphs.iter().map(|g| g.line).collect::>(); + let mut pushed_in = vec![0; 16]; + pushed_in.push(1); + assert_eq!(lines(KinsokuShoriType::PushInFirst), pushed_in, "vertical {vertical_type}"); + let mut pushed_out = vec![0; 14]; + pushed_out.extend([1, 1, 1]); + assert_eq!(lines(KinsokuShoriType::PushOutOnly), pushed_out, "vertical {vertical_type}"); + assert_eq!(lines(KinsokuShoriType::PushOutFirst), pushed_out, "vertical {vertical_type}: a push-out leaves one em"); + } + for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] { + let l = lay(KinsokuShoriType::PushInFirst, justify, false); + let (first, bracket) = (&l.glyphs[0], &l.glyphs[15]); + assert!(first.origin.x.abs() < 0.01, "{justify:?}: the line starts at the frame's left: {:?}", first.origin); + assert!(bracket.origin.x + bracket.advance <= 300.01, "{justify:?}: 」 ends inside the frame: {:?}", bracket.origin); + assert!(l.glyphs[1].origin.x < 20.0 - 0.5, "{justify:?}: the gaps close up"); + for g in &l.glyphs[..15] { + assert!(g.advance >= 18.0 - 0.01, "{justify:?}: a gap closes by at most a tenth of an em: {}", g.advance); + } + } + // A No Break run wider than the frame did not push anything in, and keeps its gaps. + let mut t = TextObject::point(Point::ZERO, "一二三四五六七八九十", CharStyle { size: 20.0, no_break: true, ..CharStyle::default() }); + t.xf = Affine::IDENTITY; + t.para.kinsoku_type = KinsokuShoriType::PushInFirst; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 150.0, 200.0).to_path(0.1)) }; + let l = layout(FontDb::global(), &t); + assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin); +} + /// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional /// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in /// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal` diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 801ed44b5..fd6e9459e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1859,6 +1859,10 @@ font-technology Variable Променливи Stylistic Стилистично Mojikumi Set Набор Mojikumi Kinsoku Set Набор Kinsoku + Kinsoku Shori Type Тип Kinsoku Shori + Push In First Първо прибиране + Push Out First Първо изтласкване + Push Out Only Само изтласкване Hard Твърдо Soft Меко Monitor RGB RGB на монитора diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d2e67c11d..9ea2a57c6 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1814,6 +1814,10 @@ digits Malayalam Malajalam Stylistic Stilsko Mojikumi Set Mojikumi skup Kinsoku Set Kinsoku skup + Kinsoku Shori Type Vrsta Kinsoku Shori + Push In First Prvo uvlačenje + Push Out First Prvo izbacivanje + Push Out Only Samo izbacivanje Hard Tvrdo Right Indent Desna uvlaka Right-to-Left Paragraph Direction Smjer pasusa s desna nalijevo diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ffb752f8a..8c166c172 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2074,6 +2074,7 @@ digits Malayalam Malaiàlam Keywords: Paraules clau: Kind Tipus Kinsoku Set Conjunt Kinsoku + Kinsoku Shori Type Tipus de Kinsoku Shori Kite Estel Knife Ganivet Knockout Group Grup knockout @@ -3117,6 +3118,9 @@ digits Malayalam Malaiàlam Puppet Warp Pins Pins de deformació de titella Puppet Warp Tool Eina de deformació de titella Purple Porpra + Push In First Primer cap a dins + Push Out First Primer cap a fora + Push Out Only Només cap a fora Put back in the toolbar Torna a posar a la barra d’eines Quality Qualitat Quality {quality} Qualitat {quality} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 97ab1c407..724a6888b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1840,6 +1840,10 @@ digits Malayalam Malajálamské Stylistic Stylistický Mojikumi Set Sada Mojikumi Kinsoku Set Sada Kinsoku + Kinsoku Shori Type Typ Kinsoku Shori + Push In First Nejprve vtáhnout + Push Out First Nejprve vysunout + Push Out Only Pouze vysunout Hard Pevné Right Indent Odsazení zprava Right-to-Left Paragraph Direction Směr odstavce zprava doleva diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 1d1741662..f008f91c0 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1645,6 +1645,10 @@ digits Malayalam Malayalam Stylistic Stilistisk Mojikumi Set Mojikumi-sæt Kinsoku Set Kinsoku-sæt + Kinsoku Shori Type Kinsoku Shori-type + Push In First Skub ind først + Push Out First Skub ud først + Push Out Only Kun skub ud Hard Hård Right Indent Højre indrykning Right-to-Left Paragraph Direction Afsnitsretning fra højre mod venstre diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 804542013..9fd518d98 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1914,6 +1914,10 @@ file-filter Templates Vorlagen Stylistic Stilistisch Mojikumi Set Mojikumi-Satz Kinsoku Set Kinsoku-Satz + Kinsoku Shori Type Kinsoku-Shori-Typ + Push In First Zuerst einziehen + Push Out First Zuerst hinausschieben + Push Out Only Nur hinausschieben Hard Hart Soft Weich Monitor RGB Monitor-RGB diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ce061787a..84a7b045c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2681,6 +2681,10 @@ theme Appearance Εμφάνιση Stylistic Στιλιστικό Mojikumi Set Σύνολο Mojikumi Kinsoku Set Σύνολο Kinsoku + Kinsoku Shori Type Τύπος Kinsoku Shori + Push In First Πρώτα προς τα μέσα + Push Out First Πρώτα προς τα έξω + Push Out Only Μόνο προς τα έξω Mono Μονοφωνικό Monochrome images Μονόχρωμες εικόνες Monospaced Σταθερού πλάτους diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f389ff27..cf5b9c75c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1912,6 +1912,10 @@ font-technology Variable Variables Stylistic Estilística Mojikumi Set Conjunto de mojikumi Kinsoku Set Conjunto de kinsoku + Kinsoku Shori Type Tipo de kinsoku shori + Push In First Primero hacia dentro + Push Out First Primero hacia fuera + Push Out Only Solo hacia fuera Hard Fuerte Soft Suave Monitor RGB RGB del monitor diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d3cf99cd8..f62cb8d52 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1801,6 +1801,10 @@ digits Malayalam Malajalam Stylistic Tyylitelty Mojikumi Set Mojikumi-joukko Kinsoku Set Kinsoku-joukko + Kinsoku Shori Type Kinsoku Shori -tyyppi + Push In First Ensin sisään + Push Out First Ensin ulos + Push Out Only Vain ulos Hard Tiukka Right Indent Oikea sisennys Right-to-Left Paragraph Direction Kappaleen suunta oikealta vasemmalle diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e82972fbc..6378b4fc0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1912,6 +1912,10 @@ font-technology Variable Variables Stylistic Stylistique Mojikumi Set Jeu de mojikumi Kinsoku Set Jeu de kinsoku + Kinsoku Shori Type Type de kinsoku shori + Push In First D’abord vers l’intérieur + Push Out First D’abord vers l’extérieur + Push Out Only Vers l’extérieur uniquement Hard Fort Soft Souple Monitor RGB RVB du moniteur diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1f7317fad..53f9bd35e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2114,6 +2114,7 @@ file-filter Templates Sablonok Keywords: Kulcsszavak: Kind Típus Kinsoku Set Kinsoku-készlet + Kinsoku Shori Type Kinsoku Shori típusa Kite Deltoid Knife Kés Knockout Group Kiütő csoport @@ -3019,6 +3020,9 @@ mark-style Japanese Japán Puppet Warp Pins Bábtorzítás tűi Puppet Warp Tool Bábtorzítás Purple Lila + Push In First Először behúzás + Push Out First Először kitolás + Push Out Only Csak kitolás Put back in the toolbar Visszahelyezés az eszköztárba Quality Minőség Quality {quality} Minőség: {quality} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index b90aadc98..fc3ff2fa4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1645,6 +1645,10 @@ digits Malayalam Malayalam Stylistic Stilistik Mojikumi Set Set Mojikumi Kinsoku Set Set Kinsoku + Kinsoku Shori Type Jenis Kinsoku Shori + Push In First Dorong masuk dahulu + Push Out First Dorong keluar dahulu + Push Out Only Hanya dorong keluar Hard Keras Right Indent Indentasi kanan Right-to-Left Paragraph Direction Arah paragraf kanan ke kiri diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9a9709b8d..d008eddbf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1939,6 +1939,10 @@ file-filter Templates Modelli Stylistic Stilistica Mojikumi Set Set mojikumi Kinsoku Set Set kinsoku + Kinsoku Shori Type Tipo di kinsoku shori + Push In First Prima all'interno + Push Out First Prima all'esterno + Push Out Only Solo all'esterno Hard Forte Soft Debole Monitor RGB RGB monitor diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b901b9336..e9ea281ee 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -68,6 +68,10 @@ digits Malayalam マラヤーラム数字 Stylistic スタイリスティック Mojikumi Set 文字組み Kinsoku Set 禁則処理 + Kinsoku Shori Type 禁則調整方式 + Push In First 追い込み優先 + Push Out First 追い出し優先 + Push Out Only 追い出しのみ Hard 強い禁則 Soft 弱い禁則 Latin 欧文 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index be58e0b36..9af41e3bc 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1548,6 +1548,10 @@ digits Malayalam მალაიალამური Stylistic სტილისტური Mojikumi Set Mojikumi-ის ნაკრები Kinsoku Set Kinsoku-ის ნაკრები + Kinsoku Shori Type Kinsoku Shori-ის ტიპი + Push In First ჯერ შეტანა + Push Out First ჯერ გატანა + Push Out Only მხოლოდ გატანა Hard მკაცრი Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b979e2613..2d45f2911 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -61,6 +61,10 @@ digits Malayalam 말라얄람어 Stylistic 스타일 Mojikumi Set 모지쿠미 세트 Kinsoku Set 긴소쿠 세트 + Kinsoku Shori Type 긴소쿠 처리 유형 + Push In First 밀어 넣기 우선 + Push Out First 밀어내기 우선 + Push Out Only 밀어내기만 Hard 강하게 Soft 약하게 Latin 라틴어 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 60b73cb0f..007d79567 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1659,6 +1659,10 @@ digits Malayalam Malayalam Stylistic Stilistisch Mojikumi Set Mojikumi-set Kinsoku Set Kinsoku-set + Kinsoku Shori Type Kinsoku Shori-type + Push In First Eerst inschuiven + Push Out First Eerst uitschuiven + Push Out Only Alleen uitschuiven Hard Hard Right Indent Rechts inspringen Right-to-Left Paragraph Direction Alinearichting van rechts naar links diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e32cb1fa1..acfb26482 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1759,6 +1759,10 @@ digits Malayalam Malajalam Stylistic Stylistyczny Mojikumi Set Zestaw mojikumi Kinsoku Set Zestaw kinsoku + Kinsoku Shori Type Typ kinsoku shori + Push In First Najpierw wciągaj + Push Out First Najpierw wypychaj + Push Out Only Tylko wypychaj Hard Ścisły Right Indent Wcięcie z prawej Right-to-Left Paragraph Direction Kierunek akapitu od prawej do lewej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index af74d69dd..bb6b55d59 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3286,6 +3286,10 @@ font-technology Variable Variáveis Stylistic Estilístico Mojikumi Set Conjunto Mojikumi Kinsoku Set Conjunto Kinsoku + Kinsoku Shori Type Tipo de Kinsoku Shori + Push In First Primeiro para dentro + Push Out First Primeiro para fora + Push Out Only Somente para fora Hard Duro Soft Macio Monospaced Monoespaçado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c497b2ad2..74ed0d8c8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1937,6 +1937,10 @@ file-filter Templates Шаблоны Stylistic Стилистические Mojikumi Set Набор mojikumi Kinsoku Set Набор kinsoku + Kinsoku Shori Type Тип kinsoku shori + Push In First Сначала втягивать + Push Out First Сначала выталкивать + Push Out Only Только выталкивать Hard Строгий Soft Мягкий Monitor RGB RGB монитора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 4b8742d00..79628cc8e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1859,6 +1859,10 @@ font-technology Variable Variabla Stylistic Stilistisk Mojikumi Set Mojikumi-uppsättning Kinsoku Set Kinsoku-uppsättning + Kinsoku Shori Type Kinsoku Shori-typ + Push In First Skjut in först + Push Out First Skjut ut först + Push Out Only Skjut endast ut Hard Hård Soft Mjuk Monitor RGB Bildskärms-RGB diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5aeae9118..b4138a6a9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1750,6 +1750,10 @@ font-technology Variable Değişken Mojikumi Mojikumi Mojikumi Set Mojikumi Seti Kinsoku Set Kinsoku Seti + Kinsoku Shori Type Kinsoku Shori Türü + Push In First Önce İçeri + Push Out First Önce Dışarı + Push Out Only Yalnızca Dışarı Hard Sert Soft Yumuşak Monitor RGB RGB'yi izleyin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 53fe1f462..8de9a4c33 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1933,6 +1933,10 @@ file-filter Templates Шаблони Stylistic Стилістичні Mojikumi Set Набір mojikumi Kinsoku Set Набір kinsoku + Kinsoku Shori Type Тип kinsoku shori + Push In First Спершу втягувати + Push Out First Спершу виштовхувати + Push Out Only Лише виштовхувати Hard Суворий Soft М’який Monitor RGB RGB монітора diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ea692ca64..6368d6994 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1953,6 +1953,7 @@ digits Hindi Hindi Keywords: Từ khóa: Kind Loại Kinsoku Set Bộ Kinsoku + Kinsoku Shori Type Kiểu Kinsoku Shori Knife Dao Knockout Group Nhóm Knockout Lab Lab @@ -2798,6 +2799,9 @@ mark-style Japanese Kiểu Nhật Puppet Warp Biến dạng con rối Puppet Warp Pins Ghim biến dạng con rối Puppet Warp Tool Công cụ Biến dạng con rối + Push In First Đẩy vào trước + Push Out First Đẩy ra trước + Push Out Only Chỉ đẩy ra Put back in the toolbar Đặt trở lại vào thanh công cụ Quality Chất lượng Quality {quality} Chất lượng {quality} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 431eb3355..c206cc98b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4835,6 +4835,10 @@ font-technology Variable 可变 Stylistic 风格化 Mojikumi Set 标点挤压集 Kinsoku Set Kinsoku 集 + Kinsoku Shori Type Kinsoku 处理方式 + Push In First 先推入 + Push Out First 先推出 + Push Out Only 只推出 Hard 强 Soft 弱 Saved Selection 1 已保存的选区 1 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c55958717..a6bb8c11f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2737,6 +2737,10 @@ file-filter Templates 範本 Stylistic 風格化 Mojikumi Set 標點間距 Kinsoku Set Kinsoku 集 + Kinsoku Shori Type Kinsoku 處理方式 + Push In First 先推入 + Push Out First 先推出 + Push Out Only 只推出 Hard 強 Soft 弱 Monitor RGB 顯示器 RGB diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 1952de032..5c3bc899c 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -2,12 +2,14 @@ //! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate, //! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian //! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and -//! Hyphenation… and picks the Single-line or Every-line Composer. +//! Hyphenation…, picks the Kinsoku Shori Type (with the East Asian options) and picks the +//! Single-line or Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ - Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, Mojikumi, NodeKind, ParaDirection, ParaStyle, SingleWordJustify, + Composer, HyphenationOptions, JustificationOptions, Justify, Kinsoku, KinsokuShoriType, Mojikumi, NodeKind, ParaDirection, ParaStyle, + SingleWordJustify, }; use super::character::{Metric, Shared, text_style}; @@ -722,6 +724,22 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } }); }); + // How a line keeps the kinsoku characters off its ends: the selected paragraphs' type is + // checked, and none is when they differ. + let kinsoku_type = shared_para(app, |p| p.kinsoku_type); + ui.add_enabled_ui(has, |ui| { + ui.menu_button(format!(" {}", tl!("Kinsoku Shori Type")), |ui| { + for (label, t, key) in [ + (tl!("Push In First"), KinsokuShoriType::PushInFirst, "pushInFirst"), + (tl!("Push Out First"), KinsokuShoriType::PushOutFirst, "pushOutFirst"), + (tl!("Push Out Only"), KinsokuShoriType::PushOutOnly, "pushOutOnly"), + ] { + if menu_item(ui, label, true, kinsoku_type == Some(t)) { + set_kinsoku_type(app, key); + } + } + }); + }); } ui.separator(); let composer = shared_composer(app); @@ -738,10 +756,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Kinsoku Shori Type (`key`: `"pushInFirst"`, `"pushOutFirst"` or `"pushOutOnly"`) for the +/// paragraphs the panel acts on ([`format`]), in one undo step. +fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) { + format(app, json!({"kinsokuType": key})); +} + /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no list, no /// indents or paragraph spacing, Hyphenate off with the default Hyphenation and Justification -/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the -/// Every-line Composer, Roman Hanging Punctuation off and no kashidas. +/// options, Auto direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, Push +/// Out Only, the Every-line Composer, Roman Hanging Punctuation off and no kashidas. fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -754,7 +778,8 @@ fn reset_panel(app: &mut VectorcraftApp) { "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, "kashida": "none", "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", - "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine", + "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "kinsokuType": "pushOutOnly", + "composer": "everyLine", "romanHangingPunctuation": false }), ); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 163427da5..48af94f97 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -106,6 +106,32 @@ fn paragraph_mixed_kinsoku_and_mojikumi_show_blank_dropdowns() { assert_eq!(shared_para(&app, |p| p.mojikumi), Some(Mojikumi::LineEndHalf)); } +/// Kinsoku Shori Type: the menu's item sets the type of the paragraphs the Type tool's selection +/// touches in one undo step, and none is checked while the selected paragraphs differ. +#[test] +fn kinsoku_shori_type_sets_the_selected_paragraphs_in_one_undo_step() { + let (mut app, id) = editing_text("あ\nい\n"); + app.session.prefs.show_east_asian_options = true; + let types = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + (text.para_at(0).kinsoku_type, text.para_at(1).kinsoku_type) + }; + select_range(&mut app, 0, 3); + let history = app.session.active().unwrap().history.undo.len(); + set_kinsoku_type(&mut app, "pushInFirst"); + assert_eq!(types(&app), (KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutOnly)); + assert_eq!(app.session.active().unwrap().history.undo.len(), history + 1); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(shared_para(&app, |p| p.kinsoku_type), None, "mixed: no item checked"); + set_kinsoku_type(&mut app, "pushOutFirst"); + assert_eq!(types(&app), (KinsokuShoriType::PushOutFirst, KinsokuShoriType::PushOutFirst)); + assert_eq!(shared_para(&app, |p| p.kinsoku_type), Some(KinsokuShoriType::PushOutFirst)); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(types(&app), (KinsokuShoriType::PushOutOnly, KinsokuShoriType::PushOutOnly)); +} + #[test] fn paragraph_mixed_indent_fields_show_blank() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/docs/mcp.md b/docs/mcp.md index ddd23ecfa..18a33f74d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4838,6 +4838,27 @@ JLREQ's level 3 line-breaking rules do (https://www.w3.org/TR/jlreq/#addendum_a3 {"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsoku":"soft"}}} ``` +## Kinsoku Shori Type + +`text.setFormat {kinsokuType: "pushInFirst"|"pushOutFirst"|"pushOutOnly"}` (Paragraph panel menu › Kinsoku Shori Type, +shown with the East Asian options) sets how the area type lines of the paragraphs of the selected type (or `ids`, or the +paragraphs a `start` / `end` range touches) keep the characters of their Kinsoku Set off their start and end. When the +character that does not fit on a line may not start the next one, or the character before it may not end the line, +**Push In First** sets it on the line, with the characters up to the next break the Kinsoku Set allows. The line then +closes its gaps next to CJK characters to fit its measure, however it is aligned, each gap by the same share of the em +of the character before it and by at most a tenth of that em; a line that cannot close up that much, or that holds a +tab, pushes out instead. **Push Out First** pushes in +the same way only when pushing out would leave the line more than an em, of the character before the break, short of +its measure. **Push Out Only** (the default: new type and documents from before the setting) always moves the +character before them to the next line with them. With Kinsoku Set None the setting does nothing. A paragraph that +pushes in and holds such characters is broken line by line, as the Single-line Composer breaks it. The setting is saved +with the paragraph (`kinsoku_type` in `para`) only when it is not `pushOutOnly`, paragraph styles carry it, and Reset +Panel sets it back to Push Out Only. One undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"kinsokuType":"pushInFirst"}}} +``` + ## Hanging punctuation (burasagari) `text.setFormat {burasagari: "none"|"standard"|"forced"}` sets the Paragraph panel menu's Burasagari (None / Regular / diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c592788df..e9343afe2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -872,6 +872,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. + The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. diff --git a/docs/type-parity.md b/docs/type-parity.md index e13ad6680..190c3565b 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -57,6 +57,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Middle Eastern: Character Direction, kashidas, Middle Eastern composers, split caret | missing | | | CJK: input methods (marked text, candidate window) | done | | | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | +| CJK: Kinsoku Shori Type (Push In First, Push Out First, Push Out Only) | done | Paragraph panel menu › Kinsoku Shori Type (`text.setFormat {kinsokuType}`), with the East Asian options ([Formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); Push Out Only is the default, and a push-in closes the line's gaps next to CJK characters by at most a tenth of an em each (VectorCraft's own limit). Evidence: `layout::wrapping_tests::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `tests::vertical::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `typecmd::area_tests::kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only` | | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | | CJK: manual tate-chu-yoko | done | Character panel menu › Tate-chu-yoko (`text.setFormat` / `text.setRangeStyle {tateChuYoko}`), with Show East Asian Options: marked characters stand side by side across one em of a vertical column, squeezed to the em when wider; two or three half-width digits still do so without the mark; horizontal type keeps the mark and does not move them. Up/Down and Left/Right settings remain. Evidence: `tests_vertical::manual_tate_chu_yoko_sets_marked_characters_across_one_em`, `typecmd::tate_chu_yoko_is_a_character_attribute_saved_only_when_on` | | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | From 4b663906e9935b5b7fe97756cf3c0b31ea181aa1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:53:56 -0800 Subject: [PATCH 18/82] Blend: tests for a pick dropped by Redo and for the history step A Redo made outside the Blend tool drops its first pick, as an edit or an Undo does, and two picks with no edit between them blend. History::step changes with each new step, Undo and Redo, also when an Undo and a new edit leave as many steps as before, and stays the same for a selection change and for an edit merged into an undo group. --- crates/engine/src/tests/blendopts.rs | 43 +++++++++++++++++++------- crates/engine/src/tests/history_mem.rs | 37 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 7332e18ec..366a19241 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -234,31 +234,50 @@ fn escape_drops_the_blend_tools_first_pick() { } #[test] -fn an_edit_or_undo_made_outside_the_blend_tool_drops_its_first_pick() { - for undo in [false, true] { +fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() { + for case in ["edit", "undo", "redo"] { + let undo = case == "undo"; let mut s = session(); let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + if case == "redo" { + s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + } s.select_tool("blend", ViewInfo::default()).unwrap(); let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); - // Undo takes away c, the object picked first; an edit recolors c, which is not picked. + // Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked. click(&mut s, if undo { 420.0 } else { 20.0 }, 20.0); assert!(!s.overlays(ViewInfo::default()).is_empty()); - if undo { - s.execute("edit.undo", &json!({})).unwrap(); - } else { - s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); - } - assert!(s.overlays(ViewInfo::default()).is_empty(), "undo {undo}: the pick is not marked"); + match case { + "undo" => s.execute("edit.undo", &json!({})).unwrap(), + "redo" => s.execute("edit.redo", &json!({})).unwrap(), + _ => s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(), + }; + assert!(s.overlays(ViewInfo::default()).is_empty(), "{case}: the pick is not marked"); let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len()); click(&mut s, 220.0, 20.0); - assert_eq!(s.doc().unwrap().selection.objects, vec![b], "undo {undo}: b is picked first"); - assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "undo {undo}: the click changes no art"); + assert_eq!(s.doc().unwrap().selection.objects, vec![b], "{case}: b is picked first"); + assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "{case}: the click changes no art"); click(&mut s, if undo { 20.0 } else { 420.0 }, 20.0); let g = s.doc().unwrap().selection.objects[0]; - assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "undo {undo}"); + assert_eq!(spec(&s, g).0, if undo { vec![a, b] } else { vec![b, c] }, "{case}"); } } +#[test] +fn two_picks_with_no_edit_between_them_blend() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + click(&mut s, 20.0, 20.0); + // Drawing the overlays between the picks does not drop the first one. + assert!(!s.overlays(ViewInfo::default()).is_empty()); + click(&mut s, 220.0, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g).0, vec![a, b]); +} + #[test] fn removing_the_blend_the_blend_tool_adds_to_ends_adding() { let mut s = session(); diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index af5d49552..23665d9ec 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -179,3 +179,40 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(first_x(&s), start); } + +/// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit +/// leave as many steps as before, and stays the same for an edit merged into an undo group and for +/// a selection change. +#[test] +fn the_history_step_changes_with_each_step_undo_and_redo() { + let mut s = session_with_rects(2); + let step = |s: &Session| s.doc().unwrap().history.step(); + let start = step(&s); + assert!(start.0 != 0 && start.1 == 0, "the Add step, and nothing to redo: {start:?}"); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + let moved = step(&s); + assert!(moved.0 != start.0 && moved.1 == 0, "{moved:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let undone = step(&s); + assert!(undone.0 == start.0 && undone.1 != 0, "{undone:?}"); + s.execute("edit.redo", &json!({})).unwrap(); + let redone = step(&s); + assert!(![start, moved, undone].contains(&redone) && redone.1 == 0, "{redone:?}"); + // An undo and a new edit: as many undo steps as after the move, at a different step. + s.execute("edit.undo", &json!({})).unwrap(); + let depth = s.doc().unwrap().history.undo.len(); + s.execute("object.move", &json!({"dx": 0, "dy": 1})).unwrap(); + let other = step(&s); + assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1); + assert!(![start, moved, undone, redone].contains(&other), "{other:?}"); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(step(&s), other, "a selection change is no step"); + s.execute("select.all", &json!({})).unwrap(); + s.begin_undo_group(); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + let grouped = step(&s); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(step(&s), grouped, "the second move is merged into the first"); + s.end_undo_group(false); + assert_ne!(grouped, other); +} From baeef1ec05717908707e646224ada0458b02a664 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 07:12:43 -0800 Subject: [PATCH 19/82] Blend: an edit merged into an open undo group drops the first pick An edit merged into the newest undo step (an undo group) left History::step unchanged, so a first pick made while the group was open survived the edit. The newest step now takes a new number whenever it is extended. --- crates/engine/src/history.rs | 7 ++++--- crates/engine/src/tests/blendopts.rs | 7 ++++++- crates/engine/src/tests/history_mem.rs | 16 ++++++++++------ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index ee6adccc4..4ce4e4599 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -129,8 +129,8 @@ impl History { } /// Which step the document is at: the newest undo step and the next redo step, by numbers no - /// other step takes (0 for none). Every new step, Undo and Redo changes it; an edit merged into - /// the newest step (an undo group) and a selection change do not. + /// other step takes (0 for none). Every new step, Undo, Redo and edit merged into the newest + /// step (an undo group) changes it; a selection change does not. pub fn step(&self) -> (u64, u64) { (self.undo.last().map_or(0, |e| e.serial), self.redo.last().map_or(0, |e| e.serial)) } @@ -161,11 +161,12 @@ impl History { } /// Estimate the newest undo step again, its next state now being `now` (the steps after it were - /// merged into it, or an undo group extended it). + /// merged into it, or an undo group extended it). It takes a new number ([`History::step`]). pub(crate) fn recost_last(&mut self, now: &Document) { if let Some(e) = self.undo.last_mut() { (e.bytes, e.next_bytes) = e.doc.retained_bytes_each(now); e.next = std::ptr::from_ref(now) as usize; + e.serial = next_serial(); } } } diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 366a19241..059357ddb 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -235,7 +235,7 @@ fn escape_drops_the_blend_tools_first_pick() { #[test] fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() { - for case in ["edit", "undo", "redo"] { + for case in ["edit", "undo", "redo", "group"] { let undo = case == "undo"; let mut s = session(); let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); @@ -243,6 +243,11 @@ fn an_edit_undo_or_redo_made_outside_the_blend_tool_drops_its_first_pick() { s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#ff0000"})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); } + // In an open undo group, the edit after the pick is merged into the group's step. + if case == "group" { + s.begin_undo_group(); + s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap(); + } s.select_tool("blend", ViewInfo::default()).unwrap(); let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); // Undo takes away c, the object picked first; an edit or a redo recolors c, which is not picked. diff --git a/crates/engine/src/tests/history_mem.rs b/crates/engine/src/tests/history_mem.rs index 23665d9ec..c14ea4aca 100644 --- a/crates/engine/src/tests/history_mem.rs +++ b/crates/engine/src/tests/history_mem.rs @@ -181,8 +181,8 @@ fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() { } /// `History::step` changes with every new step, Undo and Redo, also when an undo and a new edit -/// leave as many steps as before, and stays the same for an edit merged into an undo group and for -/// a selection change. +/// leave as many steps as before, and with an edit merged into an undo group's step. It stays the +/// same for a selection change. #[test] fn the_history_step_changes_with_each_step_undo_and_redo() { let mut s = session_with_rects(2); @@ -210,9 +210,13 @@ fn the_history_step_changes_with_each_step_undo_and_redo() { s.execute("select.all", &json!({})).unwrap(); s.begin_undo_group(); s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); - let grouped = step(&s); - s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); - assert_eq!(step(&s), grouped, "the second move is merged into the first"); - s.end_undo_group(false); + let (grouped, depth) = (step(&s), s.doc().unwrap().history.undo.len()); assert_ne!(grouped, other); + s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "the second move is merged into the first"); + let merged = step(&s); + assert!(merged != grouped && merged.1 == 0, "a merged edit moves the step: {merged:?}"); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(step(&s), merged); + s.end_undo_group(false); } From 3698a76c5df989756776369ccdb62d027c61e4cd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:09:11 -0800 Subject: [PATCH 20/82] 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 932426eaadd2af652b0885e48ce4c9f68466924c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:22:06 -0800 Subject: [PATCH 21/82] Type: a line holding right-to-left text pushes out A line with right-to-left text is reordered before it is drawn, and justification closes the gaps between its glyphs in that order, which can differ from the gaps the push-in counted. Such a line could be taken as fitting and then run past its frame. Kinsoku Shori Type now pushes it out. --- crates/text/src/layout/breaking.rs | 9 +++++++-- crates/text/src/layout/wrapping_tests.rs | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index e996cbba8..f41e4943f 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -518,8 +518,8 @@ const PUSH_IN_RUN: usize = 16; /// before it, makes room for them. Push In First tries this before it pushes out. Push Out First /// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the /// line short by more than an em of the character before `j`. Push Out Only never pushes in, and -/// neither does a line holding a tab, nor one where the characters taken in reach a space after a -/// character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging +/// neither does a line holding a tab or right-to-left text, nor one where the characters taken in +/// reach a space after a character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging /// Punctuation), the gaps that hanging punctuation keeps open are not counted. #[allow(clippy::too_many_arguments)] fn push_in( @@ -569,6 +569,11 @@ fn push_in( while last > i && g[last - 1].is_space() { last -= 1; } + // A line holding right-to-left text is reordered before it is drawn, and justification closes + // the gaps between its glyphs in that order: it pushes out. + if g[i..last].iter().any(|h| h.len > 0 && h.level.is_rtl()) { + return None; + } let punct = |k: usize| g.get(k).is_some_and(optical_punct); let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch)); let may_hang = |a: usize| { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index e6b6ad08f..ea12f601b 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -223,4 +223,13 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); + + // A line holding right-to-left text is reordered before it is drawn, so the gaps that close + // differ from the ones counted here: it pushes out. + let mixed = "一二三א五六七八九十一二三四五」六"; + let mut glyphs = full_width(mixed); + glyphs[3].level = unicode_bidi::Level::rtl(); + let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false).0; + assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); + assert_eq!(mixed_end(PushOutOnly), 14); } From 0465fb833656822627667805bc3ad03176961e0e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:36:32 -0800 Subject: [PATCH 22/82] Type: push-in closes no gap inside a cluster and measures along the line A push-in no longer counts or closes the gap between two glyphs of one shaped character, and CJK justification adds no space there either. The share of an em each gap closes by is measured along the line: at the character's Horizontal Scale, or at its Vertical Scale for an upright character in vertical type whose font's vertical metrics set its length down the column. Before, horizontal text at 200% Vertical Scale closed its gaps by twice as much. --- crates/text/src/layout/breaking.rs | 40 +++++++++++++----- crates/text/src/layout/flow.rs | 18 ++++---- crates/text/src/layout/wrapping_tests.rs | 52 +++++++++++++++++++----- crates/text/src/tests/vertical.rs | 29 +++++++++++++ 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index f41e4943f..7fbb3f39c 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -1,5 +1,5 @@ use super::region::Region; -use super::vertical::glyph_em; +use super::vertical::stands_upright; use super::{ Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph, hyphen_glyph, hyphen_points_with, is_cjk, no_line_end, no_line_start, soft_line_start, @@ -272,7 +272,7 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 { /// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone /// keeps the line from breaking before the character that overflows, `kinsoku_type` decides /// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical -/// Margin Alignment or Roman Hanging Punctuation is on. +/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -286,6 +286,7 @@ pub(super) fn break_line( kinsoku: Kinsoku, kinsoku_type: KinsokuShoriType, hanging: bool, + vertical: bool, ) -> (usize, bool) { if !width.is_finite() { if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') { @@ -335,7 +336,9 @@ pub(super) fn break_line( } return (end, false); } - if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging) { + if let Some(end) = + push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical) + { return (end, false); } break; @@ -519,8 +522,9 @@ const PUSH_IN_RUN: usize = 16; /// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the /// line short by more than an em of the character before `j`. Push Out Only never pushes in, and /// neither does a line holding a tab or right-to-left text, nor one where the characters taken in -/// reach a space after a character that may not end a line. With `hanging` (Optical Margin Alignment or Roman Hanging -/// Punctuation), the gaps that hanging punctuation keeps open are not counted. +/// reach a space after a character that may not end a line. With `hanging` (Optical Margin +/// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not +/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). #[allow(clippy::too_many_arguments)] fn push_in( g: &[SGlyph], @@ -533,6 +537,7 @@ fn push_in( kinsoku: Kinsoku, kinsoku_type: KinsokuShoriType, hanging: bool, + vertical: bool, ) -> Option { if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly { return None; @@ -583,7 +588,7 @@ fn push_in( || (a + 2 == last && punct(last - 1)) || (a + 3 == last && punct(last - 2) && quote(last - 1))) }; - let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * glyph_em(&g[a])).sum(); + let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum(); if x - width > room + EPS { return None; } @@ -593,7 +598,7 @@ fn push_in( kept -= 1; } let short = width - g[i..kept].iter().map(|h| h.adv).sum::(); - if short <= glyph_em(before) + EPS { + if short <= squeeze_em(before, vertical) + EPS { return None; } } @@ -607,11 +612,26 @@ fn push_in( } /// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them -/// CJK, outside a tate-chu-yoko block and a list marker, and not next to a space, a tab or an -/// inline graphic before it. +/// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space, +/// a tab or an inline graphic before it. fn closes(g: &[SGlyph], a: usize) -> bool { let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false }; - p.len > 0 && p.inline.is_none() && !p.is_space() && !n.is_space() && p.ch != '\t' && !n.continues_tcy() && (is_cjk(p.ch) || is_cjk(n.ch)) + p.len > 0 + && p.byte != n.byte + && p.inline.is_none() + && !p.is_space() + && !n.is_space() + && p.ch != '\t' + && !n.continues_tcy() + && (is_cjk(p.ch) || is_cjk(n.ch)) +} + +/// The em a push-in measures in, along the line: `g`'s em at its Horizontal Scale, as Mojikumi's +/// spacing is, or at its Vertical Scale when it stands upright in vertical type and the font's +/// vertical metrics set its length down the column ([`super::vertical::upright_cell`]). +pub(super) fn squeeze_em(g: &SGlyph, vertical: bool) -> f64 { + let down_the_column = vertical && stands_upright(g) && g.face.vertical_glyph(g.gid).is_some(); + g.face.units_per_em() * if down_the_column { g.sy } else { g.sx } } #[cfg(test)] diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index c28508751..a76f2080c 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -1,7 +1,7 @@ use super::bidi::{is_rtl, para_bidi_with, visual_order}; use super::breaking::{ Metrics, PUSH_IN_SQUEEZE, Pen, break_line, candidates, hangs, leader_outline, optical_hangs, optical_hangs_rtl, optical_punct, optical_share, - roman_share, set_width, tab_advance, word_width, + roman_share, set_width, squeeze_em, tab_advance, word_width, }; use super::region::Region; use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin}; @@ -343,7 +343,8 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) { Some(&(end, hyph)) if end > *i => (end, hyph, false), _ if *i < n => { - let line = |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging); + let line = + |ty| break_line(cx.text, sg, *i, width, ¶.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical); let (end, hyph) = line(para.kinsoku_type); // The line pushed characters in where it ends after the line that pushes out. let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0; @@ -595,12 +596,13 @@ fn drawn_after(index: usize, glyph: usize, after: &[Option]) -> Option], index: usize, sg: &[SGlyph]) -> bool { !held.contains(&glyph) - && drawn_after(index, glyph, after) - .and_then(|next| sg.get(glyph).zip(sg.get(next))) - .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) + && drawn_after(index, glyph, after).and_then(|next| sg.get(glyph).zip(sg.get(next))).is_some_and(|(a, b)| { + a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)) + }) } // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph @@ -668,7 +670,7 @@ fn justify_flow_line(vertical: bool, text: &str, paragraph: &ParagraphData<'_, ' // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { - let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| glyph_em(&sg[j])).sum(); + let ems: f64 = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| squeeze_em(&sg[j], vertical)).sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } @@ -773,7 +775,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.per_space; } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + justification.squeeze * glyph_em(glyph); + advance += justification.per_cjk + justification.squeeze * squeeze_em(glyph, cx.vertical); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index ea12f601b..2e62e2956 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -53,7 +53,8 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]); - let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + let (end, hyphenated) = + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphenated); assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together"); @@ -66,12 +67,13 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() { let text = "بَتَث"; let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]); - let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + let (first_end, _) = + break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(first_end, 1); assert_eq!(source_range(&glyphs, 0, first_end), 0..4); let (second_end, hyphenated) = - break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false); + break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphenated); assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together"); @@ -126,7 +128,8 @@ fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() { fn no_break_keeps_marked_characters_on_one_line() { let text = "A B"; let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); - let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, hyphen) = + break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphen); assert_eq!(end, 2, "without the mark the line ends after the space"); @@ -134,7 +137,8 @@ fn no_break_keeps_marked_characters_on_one_line() { for g in &mut marked { g.no_break = true; } - let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, hyphen) = + break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert!(!hyphen); assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); @@ -143,9 +147,9 @@ fn no_break_keeps_marked_characters_on_one_line() { let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); glyphs[2].no_break = true; glyphs[3].no_break = true; - let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(end, 2, "the marked pair moves to the next line together"); - let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false); assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); let breaks = candidates(text, &glyphs, None, Kinsoku::None); @@ -157,7 +161,8 @@ fn full_width(text: &str) -> Vec { let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); let mut glyphs = synthetic_glyphs(&specs); for g in &mut glyphs { - g.sy = 10.0 / g.face.units_per_em(); + g.sx = 10.0 / g.face.units_per_em(); + g.sy = g.sx; } glyphs } @@ -171,7 +176,7 @@ fn full_width(text: &str) -> Vec { fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly}; let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| { - break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging).0 + break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0 }; let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false); let text = "一二三四五六七八九十一二三四五」六"; @@ -223,13 +228,40 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutOnly), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushOutFirst), 12); assert_eq!(end(tabbed, 146.0, Kinsoku::Hard, PushInFirst), 12); +} +/// Kinsoku Shori Type counts only the gaps justification closes: a line holding right-to-left +/// text pushes out, the gaps close by a tenth of the em at the characters' Horizontal Scale, and +/// no gap closes inside a shaped cluster. +#[test] +fn kinsoku_shori_type_counts_the_gaps_that_close() { + use KinsokuShoriType::{PushInFirst, PushOutOnly}; // A line holding right-to-left text is reordered before it is drawn, so the gaps that close // differ from the ones counted here: it pushes out. let mixed = "一二三א五六七八九十一二三四五」六"; let mut glyphs = full_width(mixed); glyphs[3].level = unicode_bidi::Level::rtl(); - let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false).0; + let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」"); assert_eq!(mixed_end(PushOutOnly), 14); + + // The gaps close by a tenth of the em at the characters' Horizontal Scale: at 200% Vertical + // Scale five gaps still close by 5 pt, short of the 10 pt 」 needs. + let tall = "一二三四五」六"; + let mut glyphs = full_width(tall); + for g in &mut glyphs { + g.sy *= 2.0; + } + let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0; + assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」"); + + // No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to + // close by 5 pt, short of the 6 pt it overflows by. + let mut glyphs = full_width(tall); + glyphs[1].adv = 5.0; + let half = glyphs[1].clone(); + glyphs.insert(2, half); + let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0; + assert_eq!(split_end(54.0), 5, "五 is pushed out with 」"); + assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in"); } diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 334a91105..95c10cefe 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -750,6 +750,35 @@ fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() { assert!((l.glyphs[1].origin.x - 20.0).abs() < 0.01, "{:?}", l.glyphs[1].origin); } +/// Kinsoku Shori Type at 200% Vertical Scale in a column of 225 pt: the font's vertical metrics set +/// each character 40 pt long, and its five gaps close by up to 4 pt each, room for the 15 pt 」 +/// overflows by, so 」 is pushed in; Push Out Only moves 五 to the next column with it. Needs +/// craft-fonts' Shippori Mincho (skipped without it). +#[test] +fn kinsoku_shori_type_closes_a_column_by_the_em_down_the_column() { + use vectorcraft_doc::KinsokuShoriType; + if FontDb::global().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let lay = |ty: KinsokuShoriType| { + let mut t = vertical_styled( + "一二三四五」六", + CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), v_scale: 200.0, ..CharStyle::default() }, + ); + t.para.kinsoku_type = ty; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 225.0).to_path(0.1)) }; + layout(FontDb::global(), &t) + }; + let lines = |ty| lay(ty).glyphs.iter().map(|g| g.line).collect::>(); + let out = lay(KinsokuShoriType::PushOutOnly); + assert!((out.glyphs[0].advance - 40.0).abs() < 0.01, "40 pt down the column: {}", out.glyphs[0].advance); + assert_eq!(lines(KinsokuShoriType::PushInFirst), [0, 0, 0, 0, 0, 0, 1]); + let bracket = &lay(KinsokuShoriType::PushInFirst).glyphs[5]; + assert!(bracket.origin.y + bracket.advance <= 225.01, "」 ends inside the column: {:?} +{}", bracket.origin, bracket.advance); + assert_eq!(lines(KinsokuShoriType::PushOutOnly), [0, 0, 0, 0, 1, 1, 1]); +} + /// Proportional Metrics in vertical type (#966): upright glyphs take the font's proportional /// heights (`vpal`, from shaping them top to bottom) and move with them, so each glyph's ink stays in /// its shorter cell. Line-end Punctuation Half Width takes nothing more off the punctuation `vpal` From 2f2d2255ad0d71c1f0fb824264a1fea063fcf090 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 18:37:11 -0800 Subject: [PATCH 23/82] Drawing tools: Escape on a polygon, star or rounded rectangle drag puts back the option it changed While a polygon, star or rounded rectangle is dragged out, Up and Down change its side count, point count or corner radius, and Ctrl/Cmd on a star changes its inner-to-outer ratio. Escape canceled the drag but kept those values, so the next drag and the tool's options started from a shape that was never made. - ShapeTool keeps those options as they were when the drag began and Escape puts them back. A drag that is let go keeps the changed values. An option set with tool.setOption during the drag stays. - Session::tool_key saves the tool's options when a key cancels a gesture, so the saved options and General > Corner Radius hold the values Escape put back. - docs/target-app-parity.md has one line for it. --- crates/engine/src/tests/toolsettings.rs | 27 ++++++++ crates/engine/src/tooling.rs | 5 ++ crates/tools/src/shape.rs | 42 +++++++++++++ crates/tools/src/shape/tests.rs | 82 ++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 5 files changed, 154 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index d1224e05d..93b2c8c05 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -112,6 +112,33 @@ fn options_are_saved_with_the_preferences() { assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9)); } +/// Escape on a polygon or rounded rectangle drag puts back the side count or corner radius its +/// arrows changed, in the saved options and the Corner Radius preference too. +#[test] +fn escape_on_a_shape_drag_saves_the_option_it_put_back() { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + let mut s = session(); + let v = ViewInfo { smart_guides: false, ..Default::default() }; + let drag = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 180.0), v).unwrap(); + s.tool_key(ToolKey::Up, Mods::default(), v).unwrap(); + // Saves every option, the one the arrow changed included. + s.set_tool_option("length", &json!(50)); + }; + select(&mut s, "polygon"); + drag(&mut s); + assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(7)); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(s.prefs.tool_settings["polygon"]["sides"], json!(6)); + select(&mut s, "roundedRectangle"); + let radius = s.prefs.corner_radius; + drag(&mut s); + assert_eq!(s.prefs.corner_radius, radius + 1.0); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert_eq!(s.prefs.corner_radius, radius); +} + /// The Fidelity an Alt smoothing drag uses on a selected path. fn alt_smooth_fidelity(s: &mut Session) -> Option { use vectorcraft_tools::{Action, Mods, PointerEvent, PointerKind}; diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c54bda129..ff2325af9 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -467,6 +467,11 @@ impl Session { } let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); self.take_tool_panic()?; + // A key that cancels a gesture may put back options the gesture changed (Escape on an + // arc, spiral or grid drag): keep them. + if acts.contains(&Action::Cancel) { + self.keep_tool_settings(); + } self.apply_actions(acts) } diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index f8594226c..8b4f964f9 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -6,6 +6,7 @@ //! Ctrl/Cmd keeps the inner radius where it is while the outer one follows the pointer, and Alt //! keeps the star's shoulders straight (#812). The start point and //! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too. +//! Escape cancels the drag and puts back the options its keys and Ctrl/Cmd changed. use serde_json::{Value, json}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; @@ -37,6 +38,18 @@ pub struct ShapeTool { pub line_angle: f64, pub fill_line: bool, snap: DrawSnap, + /// The options a drag's keys and Ctrl/Cmd change, as they were when the drag began: Escape + /// puts them back. + before_drag: Option, +} + +/// The options that keys and Ctrl/Cmd change while a shape is dragged out. +#[derive(Clone, Copy)] +struct DragOptions { + sides: u32, + points: u32, + corner_radius: f64, + star_ratio: f64, } /// The inner-to-outer radius ratio that keeps a star of `points` points' shoulders straight (each @@ -72,9 +85,22 @@ impl ShapeTool { line_length: 100.0, line_angle: 0.0, fill_line: false, + before_drag: None, } } + fn drag_options(&self) -> DragOptions { + DragOptions { sides: self.sides, points: self.points, corner_radius: self.corner_radius, star_ratio: self.star_ratio } + } + + /// Put back the options a canceled drag changed. + fn restore_drag_options(&mut self, o: DragOptions) { + self.sides = o.sides; + self.points = o.points; + self.corner_radius = o.corner_radius; + self.star_ratio = o.star_ratio; + } + /// Ctrl/Cmd in a star drag from `start` to `p`, where the last drag showed a star with the inner /// radius `shown`: once the star has begun, the inner radius stays as the star showed it when /// the key went down, so the ratio follows the outer radius (and stays so once the key is let @@ -228,6 +254,7 @@ impl Tool for ShapeTool { self.last = ev.pos; self.began = false; self.inner = None; + self.before_drag = None; vec![] } PointerKind::Drag => { @@ -251,6 +278,7 @@ impl Tool for ShapeTool { return out; } self.began = true; + self.before_drag = Some(self.drag_options()); out.push(Action::Begin(self.label().into())); } let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle); @@ -261,6 +289,7 @@ impl Tool for ShapeTool { let Some(s) = self.start.take() else { return vec![] }; self.snap.clear(); self.inner = None; + self.before_drag = None; if self.began { self.began = false; vec![Action::Commit] @@ -309,6 +338,9 @@ impl Tool for ShapeTool { self.start = None; self.began = false; self.inner = None; + if let Some(o) = self.before_drag.take() { + self.restore_drag_options(o); + } self.snap.clear(); vec![Action::Cancel] } else { @@ -373,6 +405,16 @@ impl Tool for ShapeTool { "fillLine" => self.fill_line = v.as_bool().unwrap_or(self.fill_line), _ => {} } + // An option set while a shape is dragged out stays when Escape cancels the drag. + if let Some(b) = self.before_drag.as_mut() { + match key { + "sides" => b.sides = self.sides, + "points" => b.points = self.points, + "cornerRadius" => b.corner_radius = self.corner_radius, + "starRatio" => b.star_ratio = self.star_ratio, + _ => {} + } + } } } diff --git a/crates/tools/src/shape/tests.rs b/crates/tools/src/shape/tests.rs index da4916576..bc8e4b102 100644 --- a/crates/tools/src/shape/tests.rs +++ b/crates/tools/src/shape/tests.rs @@ -166,15 +166,16 @@ fn a_star_drag_holds_the_inner_radius_it_shows_when_ctrl_goes_down() { close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 170.0, 50.0).with_mods(ctrl))), (120.0, 40.0 * straight)); // Escape with the key held, then a new press and the key before the first move: the next star - // holds what it shows, not the canceled radius. + // has the ratio from before the canceled drag and holds the inner radius it shows, not the + // canceled one. let mut t = ShapeTool::new("star"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)); assert_eq!(t.key(&cx, ToolKey::Escape, ctrl), vec![Action::Cancel]); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); - close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 2.5)); - close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 2.5)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 50.0).with_mods(ctrl))), (10.0, 5.0)); + close(radii(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 70.0, 50.0).with_mods(ctrl))), (20.0, 5.0)); // Ctrl/Cmd pressed inside the drag threshold, where no star is shown yet. let mut t = ShapeTool::new("star"); @@ -301,6 +302,81 @@ fn rounded_rectangle_radius_is_clamped_to_its_range() { assert_eq!(t.options()["cornerRadius"], json!(MAX_CORNER_RADIUS)); } +/// Escape during a polygon, star or rounded rectangle drag puts back the side count, point count +/// or corner radius its arrows changed, for the next drag and the tool's options; a drag that is +/// let go keeps them. +#[test] +fn escape_puts_back_the_options_a_drag_changed() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + for (id, field) in [("polygon", "sides"), ("star", "points"), ("roundedRectangle", "radius")] { + let mut t = ShapeTool::new(id); + let value = |a: Vec| match a.last() { + Some(Action::Preview(_, v)) => v[field].clone(), + _ => panic!("{id}: {a:?}"), + }; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + let before = value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))); + let options = t.options(); + t.key(&cx, ToolKey::Up, Mods::default()); + let changed = value(t.key(&cx, ToolKey::Up, Mods::default())); + assert_ne!(changed, before, "{id}: the arrows change the option"); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{id}"); + assert_eq!(t.options(), options, "{id}: the tool's options"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), before, "{id}: the next drag"); + // A drag that is let go keeps what its arrows changed. + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Up, Mods::default()); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: a finished drag keeps it"); + t.key(&cx, ToolKey::Down, Mods::default()); + t.key(&cx, ToolKey::Escape, Mods::default()); + let kept = t.options(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + // Escape puts back only what this drag changed. + assert_eq!(value(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0))), changed, "{id}: only this drag"); + assert_ne!(kept, options, "{id}"); + } +} + +/// Ctrl/Cmd changes a star's inner-to-outer ratio as it is dragged out; Escape puts it back, and +/// an option set during the drag stays. +#[test] +fn escape_puts_back_the_star_ratio_and_keeps_an_option_set_during_the_drag() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let ctrl = Mods { cmd: true, ..Default::default() }; + let mut t = ShapeTool::new("star"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 90.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 50.0).with_mods(ctrl)); + assert!((t.star_ratio - 0.25).abs() < 1e-9, "{}", t.star_ratio); + t.key(&cx, ToolKey::Escape, ctrl); + assert_eq!(t.star_ratio, 0.5); + + // Options set with `tool.setOption` during the drag stay; the arrows' change goes back. + for (id, key, set) in [("polygon", "sides", json!(9)), ("star", "points", json!(8)), ("roundedRectangle", "cornerRadius", json!(30.0))] { + let mut t = ShapeTool::new(id); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.set_option(key, &set); + t.set_option("starRatio", &json!(0.3)); + t.key(&cx, ToolKey::Up, Mods::default()); + t.key(&cx, ToolKey::Escape, Mods::default()); + let o = t.options(); + assert_eq!((o[key].clone(), o["starRatio"].clone()), (set, json!(0.3)), "{id}"); + } +} + /// The start and the dragged corner land on another object's anchors (#506), hovering shows /// it before the press, and the measurement label gives the size drawn. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..7444b8c44 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -701,6 +701,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). + Escape while a polygon, star or rounded rectangle is dragged out cancels it and puts back the side count, point count, corner radius or star ratio its Up/Down and Ctrl/Cmd changed, so the next drag, the tool's options and General › Corner Radius start from the values before the canceled drag; a drag that is let go keeps them, and an option set with `tool.setOption` during the drag stays. This is VectorCraft's own rule. While a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. From 36646863fc943dfa47198a5510110fee2474c918 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:12:35 -0500 Subject: [PATCH 24/82] .ai import: Art brushes in the older form of their objects come into the Brushes panel Art brush objects whose first number is 1, as older files write them, have one field fewer before their numbers and 8 numbers at the end. The reader wanted exactly 15 and left every such brush out with a note, though an Art brush is read from its art's name alone. Scatter brushes still need all 15 numbers. --- crates/eps/src/import/ai/brush.rs | 7 +++++-- crates/eps/src/import/ai/tests.rs | 18 ++++++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/target-app-parity.md | 5 +++-- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 4c9bda1b1..f67724693 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -21,7 +21,9 @@ //! page). //! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's //! section names (one for Scatter, five for Art and Pattern, empty where there is none, each -//! after a space for Art and Pattern), more fields, and last 15 numbers. +//! after a space for Art and Pattern), more fields, and last 15 numbers. Brushes whose first +//! number is 1, as older files write them, have one field fewer before the numbers and 8 numbers; +//! an Art brush is read from its art's name alone, so either form reads. //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the //! rotation follows the path (14). @@ -247,10 +249,10 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< }) .collect(); let n = fields.last().and_then(|f| numbers(f)).ok_or(BrushError::Values)?; - let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) }; let art = || tiles.first().cloned().flatten().ok_or(BrushError::NoArt); match kind { Tool::Scatter => { + let [_, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, _] = n.as_slice() else { return Err(BrushError::Values) }; let pct = |a: f64, b: f64| (a * 100.0, b * 100.0); let values = [pct(*p2, *p3), pct(*p5, *p6), pct(*p8, *p9), (*p11, *p12)]; let codes = [*p4, *p7, *p10, *p13]; @@ -270,6 +272,7 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< } Tool::Art => DraftKind::Art { brush: ArtBrush { colorization: Colorization::None, ..ArtBrush::default() }, art: art()? }, _ => { + let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) }; let tiles: [Option; 5] = tiles.try_into().map_err(|_| BrushError::Values)?; DraftKind::Pattern { tiles, scale: p3 * 100.0 } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c883719e3..53ed6eb82 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7110,6 +7110,24 @@ fn a_pattern_brushs_five_slots_are_kept() { assert!(matches!(d.kind, brush::DraftKind::Art { ref art, .. } if art == "Ribbon art"), "{d:?}"); } +/// An Art brush in the older form (first number 1: one field fewer before its 8 numbers) is read +/// as the newer one is, with the art of its section; a Scatter brush in that form isn't (its values +/// aren't all there). +#[test] +fn an_older_art_brush_comes_in_with_its_art() { + const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0"; + let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap(); + assert!(matches!(d.kind, brush::DraftKind::Art { ref art, .. } if art == "Ribbon art"), "{d:?}"); + assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values)); + let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat(); + let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let vectorcraft_brush::BrushKind::Art(a) = brush_named(&lib, "Ribbon") else { panic!("art") }; + assert_eq!(a.art.geometric_bounds().map(|b| (b.width(), b.height())), Some((4.0, 4.0)), "the art of its section"); + assert_eq!(lib.len(), 1, "a brush naming no art is left out"); + assert!(r.warnings.iter().any(|w| w.ends_with("Brushes panel (1)")), "{:?}", r.warnings); +} + /// A brush's art filled with a pattern the file defines before it is read, with that pattern. #[test] fn a_brushs_art_takes_the_patterns_defined_before_it() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..5bd00e353 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (`.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -143,7 +143,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes (Art brushes in the older form of their objects too) replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c7f408423..c2e3431fd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (`.ai` and EPS import: older Art brush objects read; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1219,7 +1219,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. Pattern brushes and brushes that can't be read are left out with a note; brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). @@ -1703,6 +1703,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-06 | minor | Drop Shadow, Outer Glow and Inner Glow: their number fields have spinner arrows (#5367, #5368); evidence added; scores unchanged. | | 2026-11-06 | minor | Stroke Weight Up and Down (Cmd/Ctrl+Alt+. and ,) step each selected stroke's weight (#5797); evidence added; scores unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | From e6e8dde8744516035cae1de1faf6e4a5ea0ac778 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:12:00 -0500 Subject: [PATCH 25/82] Docs: keep this branch's entries that the merge with main dropped --- docs/target-app-parity.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..8463add56 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS import: older Art brush objects read; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1237,7 +1237,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). @@ -1725,6 +1725,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-06 | minor | Direct Selection: with Snap to Grid, a dragged straight segment lands its anchors on the grid (#3835); evidence added; scores unchanged. | | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: the corners of each glyph's box are points a drawn point lands on, also on a curved side (`B`), and the box turns with rotated type, its edges then Angular Guides (#5112; [Snap to Glyph options](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/snap-to-glyph-options.html)); scores and estimates unchanged. | | 2026-11-06 | minor | Drop Shadow: a swatch beside Color opens the Color Picker over the dialog, as the other effect colour fields do (#5364); correction: the #5365 entry listed Drop Shadow, whose Color is drawn outside the shared grid and had no swatch; scores unchanged. | From 120bcf102ea22e243dd91d488e4a5893cbe3476c Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:07:25 -0500 Subject: [PATCH 26/82] .ai import: an Art brush with neither form's count of numbers isn't read An Art brush object whose last field had neither 15 numbers nor the older form's 8 came in with default settings. It is now left out like other brushes whose values can't be read. An older-form Art brush keeps the Art brush's default colorization, as the note says. --- crates/eps/src/import/ai/brush.rs | 11 +++++++---- crates/eps/src/import/ai/tests.rs | 6 ++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 69e88528c..4dd1de52c 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -334,10 +334,13 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< } Tool::Art => { // The older form's 8 numbers aren't known: its brush keeps the defaults and its - // paths their drawn look. - let (colorization, plain) = match <&[f64; 15]>::try_from(n.as_slice()) { - Ok(n15) => (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)), - Err(_) => (Colorization::None, false), + // paths their drawn look. Any other count isn't a form this reads. + let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) { + (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)) + } else if n.len() == 8 { + (ArtBrush::default().colorization, false) + } else { + return Err(BrushError::Values); }; DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index f3c8be340..78e46d0cc 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -7791,6 +7791,12 @@ fn an_older_art_brush_comes_in_with_its_art() { const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0"; let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap(); assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}"); + let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") }; + assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization"); + // A count of numbers neither form has isn't read. + for bad in ["1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0", "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0"] { + assert_eq!(brush::parse_tool_object(brush::Tool::Art, "Ribbon", bad), Err(brush::BrushError::Values), "{bad}"); + } assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values)); let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat(); let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer(""))); From 7436a38f440e7112d3df7d54a9f722100f3a9a2e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 16:49:38 -0800 Subject: [PATCH 27/82] JavaScript: Object, Function, Array, Error, Math and JSON built-ins vectorcraft-js now provides the ECMAScript 5.1 built-ins: the Object property-descriptor functions, create, keys, seal and freeze (15.2), Function.prototype.bind (15.3.4.5), Array with every ES5 method (15.4), the error types (15.11), Math (15.8), JSON with revivers, replacers and indentation (15.12), and the global functions with Annex B's escape and unescape (15.1, B.2). Prototype methods are writable and configurable. The array methods work on array-like objects and visit only the indices that exist, so a sparse array of length 2^32-1 costs as much as its elements, and they see indices a callback adds. join writes a cycle back to an array as an empty string; JSON.stringify throws a TypeError on a cycle. JSON parsing, writing and the reviver walk use no native recursion. Every loop takes fuel and every result is checked against the heap limit, so new Array(4294967295).join("x") ends with the heap limit and a huge sort or stringify ends with a limit. cargo xtask test262 also runs the suite's ES5 built-in tests and fetches json2.js at a pinned commit, checked against its SHA-256, to compare its output with the built-in JSON once Date, String.prototype.replace and RegExp exist. --- crates/js/src/builtins.rs | 181 ++++++- crates/js/src/builtins/array.rs | 843 +++++++++++++++++++++++++++++++ crates/js/src/builtins/global.rs | 339 +++++++++++++ crates/js/src/builtins/json.rs | 677 +++++++++++++++++++++++++ crates/js/src/builtins/math.rs | 209 ++++++++ crates/js/src/builtins/object.rs | 302 +++++++++++ crates/js/src/builtins_tests.rs | 503 ++++++++++++++++++ crates/js/src/heap.rs | 32 +- crates/js/src/interp.rs | 376 +++++++++++++- crates/js/src/lib.rs | 12 +- crates/js/src/realm.rs | 1 + crates/js/tests/json2.rs | 83 +++ crates/js/tests/test262.rs | 75 ++- docs/target-app-parity.md | 3 +- xtask/src/test262.rs | 62 ++- 15 files changed, 3639 insertions(+), 59 deletions(-) create mode 100644 crates/js/src/builtins/array.rs create mode 100644 crates/js/src/builtins/global.rs create mode 100644 crates/js/src/builtins/json.rs create mode 100644 crates/js/src/builtins/math.rs create mode 100644 crates/js/src/builtins/object.rs create mode 100644 crates/js/src/builtins_tests.rs create mode 100644 crates/js/tests/json2.rs diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index b16aa9c9e..df698d42f 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -1,15 +1,28 @@ -//! The built-in objects the interpreter itself needs (ECMA-262 5.1 section 15): `Object` and the -//! basic methods of `Object.prototype`, `Function` with `Function.prototype.call`, `apply` and -//! `toString`, `eval`, the error constructors and `Error.prototype.toString`, and the prototypes -//! primitive values look properties up on. The rest of the standard library is not here yet. +//! The built-in objects (ECMA-262 5.1 section 15): `Object` with `Object.prototype` and the +//! ES5 statics, `Function` with `Function.prototype.call`, `apply`, `bind` and `toString`, +//! `Array`, the error constructors, `Math`, `JSON`, the global functions and constants, Annex +//! B's `escape` and `unescape`, and the prototypes primitive values look properties up on. +//! `String`, `Number`, `Boolean`, `RegExp` and `Date` are not here yet. +//! +//! Every loop in a built-in takes a unit of fuel for each step, and every string, array, object +//! or property it makes is checked against the heap limit before it is made, so no built-in can +//! run past [`crate::Limits`]. + +mod array; +mod global; +mod json; +mod math; +mod object; use std::rc::Rc; use crate::bytecode::{CodeKind, Source}; use crate::compiler::{CompileFail, compile_constructed_function}; -use crate::heap::{Class, Func, HIDDEN, NativeFn, NativeKind, ObjId, Object, Prop, key, string_bytes}; -use crate::interp::{Abrupt, ErrorType, Interp, JsResult}; -use crate::value::{Str, Value}; +use crate::heap::{ + BOUND_ARG_BYTES, Class, Func, HIDDEN, Key, NativeFn, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, key, string_bytes, +}; +use crate::interp::{Abrupt, Desc, ErrorType, Interp, JsResult}; +use crate::value::{Str, Value, index_key, number_to_str, to_uint32}; fn alloc(interp: &mut Interp, class: Class, proto: Option) -> ObjId { interp.heap.alloc_object(Object::new(class, proto)) @@ -66,7 +79,7 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto)); - interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean, Some(object_proto)); + interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean(false), Some(object_proto)); interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; if let Some(t) = interp.heap.object_mut(thrower) { @@ -96,9 +109,134 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { data(interp, function_proto, "call", Value::Object(call), HIDDEN)?; let apply = native(interp, "apply", unreachable_native, 2, NativeKind::Apply, false)?; data(interp, function_proto, "apply", Value::Object(apply), HIDDEN)?; + method(interp, function_proto, "bind", function_bind, 1)?; method(interp, function_proto, "toString", function_to_string, 0)?; - install_errors(interp, object_proto) + install_errors(interp, object_proto)?; + object::install(interp, object)?; + array::install(interp)?; + math::install(interp)?; + json::install(interp)?; + global::install(interp) +} + +// ---- helpers for the built-ins ---- + +/// ToInteger (section 9.4). +fn to_integer(interp: &mut Interp, v: &Value) -> JsResult { + let n = interp.to_number(v)?; + Ok(if n.is_nan() { 0.0 } else { n.trunc() }) +} + +/// The property name of the index `n`, which may be past the largest array index. +fn key_of(n: f64) -> Key { + if (0.0..4_294_967_295.0).contains(&n) && n.fract() == 0.0 { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + return index_key(n as u32); + } + number_to_str(n).flat() +} + +/// ToUint32 of an object's `length`. +fn length_of(interp: &mut Interp, o: ObjId) -> JsResult { + let len = interp.get_from(o, &key("length"), &Value::Object(o))?; + Ok(to_uint32(interp.to_number(&len)?)) +} + +fn get_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult { + interp.get_from(o, &key_of(k), &Value::Object(o)) +} + +fn has_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult { + interp.has_property(o, &key_of(k)) +} + +/// The estimated bytes a new element adds. +const ELEMENT_BYTES: usize = PROP_BYTES + 32 + 2 * 10; + +/// `o[k] = v` as the array methods assign: a failure throws. +fn put_at(interp: &mut Interp, o: ObjId, k: f64, v: Value) -> JsResult<()> { + interp.reserve_heap(ELEMENT_BYTES)?; + interp.put(o, key_of(k), v, true) +} + +fn delete_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<()> { + interp.delete(o, &key_of(k), true).map(drop) +} + +/// Defines `a[k]` as a writable, enumerable and configurable data property, as the methods that +/// build a new array do (DefineOwnProperty with `throw` false). +fn create_at(interp: &mut Interp, a: ObjId, k: f64, v: Value) -> JsResult<()> { + interp.reserve_heap(ELEMENT_BYTES)?; + interp.define_own_property(a, key_of(k), &Desc::data(v, OPEN), false).map(drop) +} + +/// A new array with the given length and no elements. +fn new_array(interp: &mut Interp, length: u32) -> JsResult { + interp.reserve_heap(OBJECT_BYTES)?; + let proto = interp.intrinsics.array_proto; + Ok(interp.heap.alloc_object(Object::new(Class::Array { length, length_writable: true }, Some(proto)))) +} + +/// A new string from code units, checked against the heap limit and charged to the heap. +fn new_string(interp: &mut Interp, units: Vec) -> JsResult { + let bytes = string_bytes(units.len()); + interp.reserve_heap(bytes)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + Ok(Value::String(Str::from_units(units))) +} + +/// A string the built-ins build piece by piece: each piece is checked against the longest string +/// the heap limit allows and its bytes are reserved before it is added. Everything the caller +/// still needs must be reachable from the roots. +struct Builder { + units: Vec, + /// Code units already reserved and charged to the heap. + charged: usize, +} + +impl Builder { + fn new() -> Self { + Builder { units: Vec::new(), charged: 0 } + } + + /// Checks that `more` code units fit, reserving heap for them. + fn make_room(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> { + let want = self.units.len().saturating_add(more); + if want > interp.limits.heap_bytes / 4 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + if want > self.charged { + let grow = (want - self.charged).max(self.charged / 2).max(256).min((interp.limits.heap_bytes / 4).saturating_sub(self.charged)); + interp.reserve_heap(2 * grow)?; + interp.heap.bytes = interp.heap.bytes.saturating_add(2 * grow); + self.charged += grow; + } + Ok(()) + } + + fn push_units(&mut self, interp: &mut Interp, units: &[u16]) -> JsResult<()> { + self.make_room(interp, units.len())?; + self.units.extend_from_slice(units); + Ok(()) + } + + fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { + let units: Vec = s.encode_utf16().collect(); + self.push_units(interp, &units) + } + + /// The string built, whose bytes were charged as it grew. + fn finish(self) -> Value { + Value::String(Str::from_units(self.units)) + } +} + +fn require_callable(interp: &mut Interp, v: &Value, what: &str) -> JsResult<()> { + if interp.is_callable(v) { + return Ok(()); + } + Err(interp.type_error(&format!("{what} is not a function"))) } fn install_errors(interp: &mut Interp, object_proto: ObjId) -> JsResult<()> { @@ -278,11 +416,36 @@ fn function_to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> } Some(Func::Native { name, .. }) => format!("function {name}() {{ [native code] }}"), Some(Func::Host { name, .. }) => format!("function {name}() {{ [native code] }}"), + Some(Func::Bound { .. }) => "function () { [native code] }".to_string(), None => return Err(interp.type_error("Function.prototype.toString was called on a value that is not a function")), }; Ok(Value::str(&text)) } +/// `Function.prototype.bind` (section 15.3.4.5). +fn function_bind(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + require_callable(interp, &this, "the value bind was called on")?; + let Some(target) = this.as_object() else { return Err(interp.type_error("the value bind was called on is not a function")) }; + let bound_this = arg(args, 0); + let bound_args: Rc<[Value]> = args.get(1..).unwrap_or_default().into(); + let target_length = match interp.get_from(target, &key("length"), &this)? { + Value::Number(n) => n, + _ => 0.0, + }; + let length = (target_length - bound_args.len() as f64).max(0.0); + interp.reserve_heap(OBJECT_BYTES + 4 * PROP_BYTES + BOUND_ARG_BYTES * bound_args.len())?; + let proto = interp.intrinsics.function_proto; + let class = Class::Function(Func::Bound { target, this: bound_this, args: bound_args.clone() }); + let f = interp.heap.alloc_object(Object::new(class, Some(proto))); + interp.heap.bytes = interp.heap.bytes.saturating_add(BOUND_ARG_BYTES * bound_args.len()); + interp.define(f, key("length"), Prop::Data { value: Value::Number(length), flags: 0 })?; + let thrower = Some(interp.intrinsics.throw_type_error); + for name in ["caller", "arguments"] { + interp.define(f, key(name), Prop::Accessor { get: thrower, set: thrower, flags: 0 })?; + } + Ok(Value::Object(f)) +} + /// The global `eval` called indirectly (section 15.1.2.1.1): global code in the global /// environment. fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs new file mode 100644 index 000000000..7eda02a60 --- /dev/null +++ b/crates/js/src/builtins/array.rs @@ -0,0 +1,843 @@ +//! `Array` (ECMA-262 5.1 section 15.4): the constructor, `Array.isArray` and every method of +//! `Array.prototype`. The methods are generic: they read `length` and the elements of any object. +//! +//! A method that walks a range of indices visits only the indices that can hold an element when +//! the range is much longer than the object has properties (a sparse array such as +//! `new Array(4294967295)`), so a long empty range costs nothing and no index is allocated for +//! it. The list of indices is made again whenever a script adds an index property to any object +//! while the method runs. Every index visited takes a unit of fuel. + +use super::{ + Builder, ELEMENT_BYTES, arg, constructor, create_at, delete_at, get_at, has_at, length_of, method, native, new_array, object_to_string, put_at, + require_callable, to_integer, +}; +use crate::heap::{Class, NativeKind, ObjId}; +use crate::interp::{Abrupt, Interp, JsResult, strict_eq}; +use crate::value::{Str, Value, to_uint32}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let proto = interp.intrinsics.array_proto; + let array = native(interp, "Array", array_ctor, 1, NativeKind::Plain, true)?; + constructor(interp, "Array", array, proto)?; + method(interp, array, "isArray", is_array, 1)?; + let methods: [(&'static str, crate::heap::NativeFn, u32); 21] = [ + ("toString", to_string, 0), + ("toLocaleString", to_locale_string, 0), + ("concat", concat, 1), + ("join", join, 1), + ("pop", pop, 0), + ("push", push, 1), + ("reverse", reverse, 0), + ("shift", shift, 0), + ("slice", slice, 2), + ("sort", sort, 1), + ("splice", splice, 2), + ("unshift", unshift, 1), + ("indexOf", index_of, 1), + ("lastIndexOf", last_index_of, 1), + ("every", every, 1), + ("some", some, 1), + ("forEach", for_each, 1), + ("map", map, 1), + ("filter", filter, 1), + ("reduce", reduce, 1), + ("reduceRight", reduce_right, 1), + ]; + for (name, f, length) in methods { + method(interp, proto, name, f, length)?; + } + Ok(()) +} + +// ---- visiting indices ---- + +/// Ranges at most this long are visited index by index. +const DENSE_SPAN: u64 = 4096; + +/// How an index an object holds names an index to visit. +#[derive(Clone, Copy)] +enum Map { + /// The index `p` asks for a visit of `p - shift`. + Shift(i64), + /// The index `p` asks for a visit of `p` and of `mirror - p`. + Mirror(i64), +} + +/// The indices of a range, in order, that an operation must visit. +struct Indices { + obj: ObjId, + /// The range left to visit, `lo` included and `hi` not. + lo: u64, + hi: u64, + ascending: bool, + maps: Vec, + /// The indices left when the range is visited sparsely. + sparse: Option, +} + +struct Sparse { + list: Vec, + pos: usize, + /// [`crate::heap::Heap::index_epoch`] when the list was made. + epoch: u64, +} + +/// The canonical numeric property name `k` stands for, up to 15 digits. +fn numeric_index(k: &[u16]) -> Option { + if k.is_empty() || k.len() > 15 || (k.len() > 1 && k.first() == Some(&u16::from(b'0'))) { + return None; + } + let mut n = 0u64; + for &u in k { + let d = u.checked_sub(u16::from(b'0')).filter(|d| *d <= 9)?; + n = n * 10 + u64::from(d); + } + Some(n) +} + +/// The objects of the prototype chain from `obj`, with how many properties (and String object +/// indices) each holds. +fn chain(interp: &Interp, obj: ObjId) -> Vec<(ObjId, usize)> { + let mut out = Vec::new(); + let mut cur = Some(obj); + while let Some(id) = cur { + let Some(o) = interp.heap.object(id) else { break }; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + out.push((id, o.props.len().saturating_add(string_len))); + cur = o.proto; + } + out +} + +impl Indices { + fn new(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64, ascending: bool, maps: Vec) -> JsResult { + let mut it = Indices { obj, lo, hi, ascending, maps, sparse: None }; + if hi.saturating_sub(lo) > DENSE_SPAN { + let present: usize = chain(interp, obj).iter().map(|(_, n)| *n).fold(0, usize::saturating_add); + if hi - lo > (present as u64).saturating_mul(8) { + it.sparse = Some(it.gather(interp)?); + } + } + Ok(it) + } + + fn up(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult { + Self::new(interp, obj, lo, hi, true, vec![Map::Shift(0)]) + } + + fn down(interp: &mut Interp, obj: ObjId, lo: u64, hi: u64) -> JsResult { + Self::new(interp, obj, lo, hi, false, vec![Map::Shift(0)]) + } + + /// Lists the indices left to visit from the properties of the object and its prototypes. + /// Each property takes a unit of fuel. + fn gather(&self, interp: &mut Interp) -> JsResult { + let epoch = interp.heap.index_epoch; + let mut list: Vec = Vec::new(); + for (id, n) in chain(interp, self.obj) { + for _ in 0..n { + interp.use_fuel()?; + } + interp.reserve_heap(n.saturating_mul(8).saturating_mul(self.maps.len() + 1))?; + let Some(o) = interp.heap.object(id) else { continue }; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + let present = (0..string_len as u64).chain(o.props.iter().filter_map(|(k, _)| numeric_index(k))); + for p in present { + for map in &self.maps { + let p = p as i64; + let ks = match *map { + Map::Shift(s) => [Some(p - s), None], + Map::Mirror(m) => [Some(p), Some(m - p)], + }; + for k in ks.into_iter().flatten() { + if k >= 0 && (k as u64) >= self.lo && (k as u64) < self.hi { + list.push(k as u64); + } + } + } + } + } + if self.ascending { + list.sort_unstable(); + } else { + list.sort_unstable_by(|a, b| b.cmp(a)); + } + list.dedup(); + Ok(Sparse { list, pos: 0, epoch }) + } + + /// The next index to visit; it takes a unit of fuel. + fn next(&mut self, interp: &mut Interp) -> JsResult> { + if self.lo >= self.hi { + return Ok(None); + } + if self.sparse.as_ref().is_some_and(|s| s.epoch != interp.heap.index_epoch) { + self.sparse = Some(self.gather(interp)?); + } + let k = match &mut self.sparse { + None if self.ascending => self.lo, + None => self.hi - 1, + Some(s) => { + let Some(&k) = s.list.get(s.pos) else { + self.lo = self.hi; + return Ok(None); + }; + s.pos += 1; + k + } + }; + if self.ascending { + self.lo = k + 1; + } else { + self.hi = k; + } + interp.use_fuel()?; + Ok(Some(k)) + } +} + +/// The element at `k` when the object or its prototypes have one. +fn element(interp: &mut Interp, o: ObjId, k: u64) -> JsResult> { + let k = k as f64; + if !has_at(interp, o, k)? { + return Ok(None); + } + get_at(interp, o, k).map(Some) +} + +/// ToObject of `this`, kept reachable while `f` runs. +fn with_this(interp: &mut Interp, this: &Value, f: impl FnOnce(&mut Interp, ObjId) -> JsResult) -> JsResult { + let o = interp.to_object(this)?; + interp.rooted(&[Value::Object(o)], |interp| f(interp, o)) +} + +/// The start a relative index gives within `0..=len` (`slice` and `splice`). +fn relative(interp: &mut Interp, v: &Value, len: u32) -> JsResult { + let n = to_integer(interp, v)?; + let len = f64::from(len); + Ok(if n < 0.0 { (len + n).max(0.0) } else { n.min(len) } as u64) +} + +fn number(k: u64) -> Value { + Value::Number(k as f64) +} + +// ---- the constructor ---- + +/// `Array(…)` and `new Array(…)` (sections 15.4.1 and 15.4.2). +fn array_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + if let [Value::Number(n)] = args { + let len = to_uint32(*n); + if f64::from(len) != *n { + return Err(interp.throw(crate::interp::ErrorType::Range, "invalid array length")); + } + return new_array(interp, len).map(Value::Object); + } + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + for (i, v) in args.iter().enumerate() { + interp.use_fuel()?; + create_at(interp, a, i as f64, v.clone())?; + } + Ok(Value::Object(a)) + }) +} + +/// `Array.isArray` (section 15.4.3.2). +fn is_array(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let a = arg(args, 0).as_object().and_then(|o| interp.heap.object(o)).is_some_and(|o| matches!(o.class, Class::Array { .. })); + Ok(Value::Bool(a)) +} + +// ---- conversions ---- + +/// `Array.prototype.toString` (section 15.4.4.2). +fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let join = interp.get_from(o, &crate::heap::key("join"), &Value::Object(o))?; + if interp.is_callable(&join) { + return interp.call(&join, &Value::Object(o), &[]); + } + object_to_string(interp, Value::Object(o), &[], false) + }) +} + +/// Joins the elements `0..len` of `o`, each turned into a string by `item`, with `sep` between +/// them. A cycle back to an object being joined joins as an empty string. +fn join_with(interp: &mut Interp, o: ObjId, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { + if interp.join_stack.contains(&o) { + return Ok(Value::str("")); + } + let len = length_of(interp, o)?; + if len == 0 { + return Ok(Value::str("")); + } + // A result that cannot fit is refused before any element is read. + let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); + if least > (interp.limits.heap_bytes / 4) as u64 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.join_stack.push(o); + let r = join_elements(interp, o, len, sep, item); + interp.join_stack.pop(); + r +} + +fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { + let mut out = Builder::new(); + // Separators written so far: element `k` follows `k` of them. + let mut seps = 0u64; + let mut it = Indices::up(interp, o, 0, u64::from(len))?; + while let Some(k) = it.next(interp)? { + let v = get_at(interp, o, k as f64)?; + let Some(s) = item(interp, v)? else { continue }; + for _ in seps..k { + out.push_units(interp, sep)?; + } + seps = k; + out.push_units(interp, &s.flat())?; + } + for _ in seps..u64::from(len - 1) { + out.push_units(interp, sep)?; + } + Ok(out.finish()) +} + +/// An element as `join` writes it: nothing for `undefined` and `null`. +fn join_item(interp: &mut Interp, v: Value) -> JsResult> { + if v.is_nullish() { + return Ok(None); + } + interp.to_str(&v).map(Some) +} + +/// An element as `toLocaleString` writes it (section 15.4.4.3). +fn locale_item(interp: &mut Interp, v: Value) -> JsResult> { + if v.is_nullish() { + return Ok(None); + } + let e = interp.to_object(&v)?; + let f = interp.get_from(e, &crate::heap::key("toLocaleString"), &Value::Object(e))?; + require_callable(interp, &f, "an element's toLocaleString")?; + let s = interp.call(&f, &Value::Object(e), &[])?; + interp.to_str(&s).map(Some) +} + +/// `Array.prototype.join` (section 15.4.4.5). +fn join(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let sep = match arg(args, 0) { + Value::Undefined => Str::from(","), + v => interp.to_str(&v)?, + }; + join_with(interp, o, &sep.flat(), join_item) + }) +} + +/// `Array.prototype.toLocaleString` (section 15.4.4.3). +fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| join_with(interp, o, &[u16::from(b',')], locale_item)) +} + +// ---- methods that change the object ---- + +/// `Array.prototype.concat` (section 15.4.4.4). +fn concat(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut n = 0u64; + for e in std::iter::once(Value::Object(o)).chain(args.iter().cloned()) { + interp.use_fuel()?; + let spread = e.as_object().filter(|&id| interp.heap.object(id).is_some_and(|x| matches!(x.class, Class::Array { .. }))); + let Some(e) = spread else { + create_at(interp, a, n as f64, e)?; + n += 1; + continue; + }; + let len = u64::from(length_of(interp, e)?); + let mut it = Indices::up(interp, e, 0, len)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, e, k)? { + create_at(interp, a, (n + k) as f64, v)?; + } + } + n += len; + } + set_length(interp, a, n as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +fn set_length(interp: &mut Interp, o: ObjId, n: f64) -> JsResult<()> { + interp.put(o, crate::heap::key("length"), Value::Number(n), true) +} + +/// `Array.prototype.pop` (section 15.4.4.6). +fn pop(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + if len == 0 { + set_length(interp, o, 0.0)?; + return Ok(Value::Undefined); + } + let last = f64::from(len - 1); + let v = get_at(interp, o, last)?; + interp.rooted(std::slice::from_ref(&v), |interp| { + delete_at(interp, o, last)?; + set_length(interp, o, last) + })?; + Ok(v) + }) +} + +/// `Array.prototype.push` (section 15.4.4.7). +fn push(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let mut n = f64::from(length_of(interp, o)?); + for v in args { + interp.use_fuel()?; + put_at(interp, o, n, v.clone())?; + n += 1.0; + } + set_length(interp, o, n)?; + Ok(Value::Number(n)) + }) +} + +/// `Array.prototype.reverse` (section 15.4.4.8). +fn reverse(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let middle = len / 2; + let mirror = len as i64 - 1; + let mut it = Indices::new(interp, o, 0, middle, true, vec![Map::Mirror(mirror)])?; + while let Some(lower) = it.next(interp)? { + let upper = len - lower - 1; + let lower_value = element(interp, o, lower)?; + let upper_value = interp.rooted(lower_value.as_slice(), |interp| element(interp, o, upper))?; + let mut roots: Vec = lower_value.iter().cloned().collect(); + roots.extend(upper_value.iter().cloned()); + interp.rooted(&roots, |interp| match (lower_value, upper_value) { + (Some(l), Some(u)) => { + put_at(interp, o, lower as f64, u)?; + put_at(interp, o, upper as f64, l) + } + (None, Some(u)) => { + put_at(interp, o, lower as f64, u)?; + delete_at(interp, o, upper as f64) + } + (Some(l), None) => { + delete_at(interp, o, lower as f64)?; + put_at(interp, o, upper as f64, l) + } + (None, None) => Ok(()), + })?; + } + Ok(Value::Object(o)) + }) +} + +/// Moves the element at `from` to `to`, or deletes `to` when there is none at `from`. +fn move_element(interp: &mut Interp, o: ObjId, from: u64, to: u64) -> JsResult<()> { + match element(interp, o, from)? { + Some(v) => put_at(interp, o, to as f64, v), + None => delete_at(interp, o, to as f64), + } +} + +/// `Array.prototype.shift` (section 15.4.4.9). +fn shift(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + if len == 0 { + set_length(interp, o, 0.0)?; + return Ok(Value::Undefined); + } + let first = get_at(interp, o, 0.0)?; + interp.rooted(std::slice::from_ref(&first), |interp| { + let mut it = Indices::new(interp, o, 1, len, true, vec![Map::Shift(0), Map::Shift(-1)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k, k - 1)?; + } + delete_at(interp, o, (len - 1) as f64)?; + set_length(interp, o, (len - 1) as f64) + })?; + Ok(first) + }) +} + +/// `Array.prototype.unshift` (section 15.4.4.13). +fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let count = args.len() as u64; + if count > 0 { + let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; + let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k - 1, k + count - 1)?; + } + } + for (j, v) in args.iter().enumerate() { + interp.use_fuel()?; + put_at(interp, o, j as f64, v.clone())?; + } + let n = (len + count) as f64; + set_length(interp, o, n)?; + Ok(Value::Number(n)) + }) +} + +/// `Array.prototype.slice` (section 15.4.4.10). +fn slice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let start = relative(interp, &arg(args, 0), len)?; + let end = match arg(args, 1) { + Value::Undefined => u64::from(len), + v => relative(interp, &v, len)?, + }; + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut it = Indices::up(interp, o, start, end)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? { + create_at(interp, a, (k - start) as f64, v)?; + } + } + set_length(interp, a, end.saturating_sub(start) as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +/// `Array.prototype.splice` (section 15.4.4.12). +fn splice(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let start = relative(interp, &arg(args, 0), len)?; + let len = u64::from(len); + let delete_count = match args.get(1) { + // Without a count, everything from the start is deleted, as later editions and + // every implementation do. + None if !args.is_empty() => len - start, + None => 0, + Some(v) => to_integer(interp, v)?.max(0.0).min((len - start) as f64) as u64, + }; + let items = args.get(2..).unwrap_or_default(); + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + let mut it = Indices::up(interp, o, start, start + delete_count)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? { + create_at(interp, a, (k - start) as f64, v)?; + } + } + set_length(interp, a, delete_count as f64)?; + move_tail(interp, o, len, start, delete_count, items.len() as u64)?; + for (j, v) in items.iter().enumerate() { + interp.use_fuel()?; + put_at(interp, o, (start + j as u64) as f64, v.clone())?; + } + set_length(interp, o, (len - delete_count + items.len() as u64) as f64)?; + Ok(Value::Object(a)) + }) + }) +} + +/// Moves the elements after a spliced range so that `item_count` items replace `delete_count` +/// elements at `start` (section 15.4.4.12, steps 12 and 13). +fn move_tail(interp: &mut Interp, o: ObjId, len: u64, start: u64, delete_count: u64, item_count: u64) -> JsResult<()> { + let (dc, ic) = (delete_count as i64, item_count as i64); + if item_count < delete_count { + let mut it = Indices::new(interp, o, start, len - delete_count, true, vec![Map::Shift(dc), Map::Shift(ic)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k + delete_count, k + item_count)?; + } + let mut it = Indices::down(interp, o, len - delete_count + item_count, len)?; + while let Some(k) = it.next(interp)? { + delete_at(interp, o, k as f64)?; + } + } else if item_count > delete_count { + let mut it = Indices::new(interp, o, start + 1, len - delete_count + 1, false, vec![Map::Shift(dc - 1), Map::Shift(ic - 1)])?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k + delete_count - 1, k + item_count - 1)?; + } + } + Ok(()) +} + +// ---- sort ---- + +/// A stable merge sort whose comparison may fail. `greater(a, b)` says whether `a` sorts after +/// `b`; each comparison takes a unit of fuel. +fn merge_sort(interp: &mut Interp, items: &mut Vec, mut greater: impl FnMut(&mut Interp, &T, &T) -> JsResult) -> JsResult<()> { + let n = items.len(); + let mut width = 1; + let mut buf: Vec = Vec::with_capacity(n); + while width < n { + buf.clear(); + let mut lo = 0; + while lo < n { + let mid = (lo + width).min(n); + let hi = (lo + 2 * width).min(n); + let (mut i, mut j) = (lo, mid); + while i < mid && j < hi { + let (Some(a), Some(b)) = (items.get(i), items.get(j)) else { break }; + interp.use_fuel()?; + if greater(interp, a, b)? { + buf.push(b.clone()); + j += 1; + } else { + buf.push(a.clone()); + i += 1; + } + } + buf.extend(items.get(i..mid).unwrap_or_default().iter().cloned()); + buf.extend(items.get(j..hi).unwrap_or_default().iter().cloned()); + lo = hi; + } + std::mem::swap(items, &mut buf); + width *= 2; + } + Ok(()) +} + +/// `Array.prototype.sort` (section 15.4.4.11). The elements are read, sorted with `undefined` +/// after every other value, written back from index 0, and the indices after them are deleted. +fn sort(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + let compare = arg(args, 0); + if !matches!(compare, Value::Undefined) { + require_callable(interp, &compare, "the comparison")?; + } + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let mark = interp.temp_roots.len(); + let r = sort_elements(interp, o, len, &compare); + interp.temp_roots.truncate(mark); + r?; + Ok(Value::Object(o)) + }) +} + +fn sort_elements(interp: &mut Interp, o: ObjId, len: u64, compare: &Value) -> JsResult<()> { + let mut values = Vec::new(); + let mut undefined = 0u64; + let mut it = Indices::up(interp, o, 0, len)?; + while let Some(k) = it.next(interp)? { + match element(interp, o, k)? { + Some(Value::Undefined) => undefined += 1, + Some(v) => { + interp.reserve_heap(ELEMENT_BYTES)?; + interp.temp_roots.push(v.clone()); + values.push(v); + } + None => {} + } + } + if matches!(compare, Value::Undefined) { + let mut keyed = Vec::with_capacity(values.len()); + for v in values { + interp.use_fuel()?; + let s = interp.to_str(&v)?.flat(); + interp.reserve_heap(crate::heap::string_bytes(s.len()))?; + keyed.push((s, v)); + } + merge_sort(interp, &mut keyed, |_, a, b| Ok(a.0 > b.0))?; + values = keyed.into_iter().map(|(_, v)| v).collect(); + } else { + merge_sort(interp, &mut values, |interp, a, b| { + let r = interp.call(compare, &Value::Undefined, &[a.clone(), b.clone()])?; + Ok(interp.to_number(&r)? > 0.0) + })?; + } + let mut i = 0u64; + for v in values { + interp.use_fuel()?; + put_at(interp, o, i as f64, v)?; + i += 1; + } + for _ in 0..undefined { + interp.use_fuel()?; + put_at(interp, o, i as f64, Value::Undefined)?; + i += 1; + } + let mut it = Indices::down(interp, o, i, len)?; + while let Some(k) = it.next(interp)? { + delete_at(interp, o, k as f64)?; + } + Ok(()) +} + +// ---- searching ---- + +/// `Array.prototype.indexOf` (section 15.4.4.14). +fn index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = f64::from(length_of(interp, o)?); + if len == 0.0 { + return Ok(Value::Number(-1.0)); + } + let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { 0.0 }; + if n >= len { + return Ok(Value::Number(-1.0)); + } + let start = if n >= 0.0 { n } else { (len + n).max(0.0) }; + let target = arg(args, 0); + let mut it = Indices::up(interp, o, start as u64, len as u64)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? + && strict_eq(&v, &target) + { + return Ok(number(k)); + } + } + Ok(Value::Number(-1.0)) + }) +} + +/// `Array.prototype.lastIndexOf` (section 15.4.4.15). +fn last_index_of(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = f64::from(length_of(interp, o)?); + if len == 0.0 { + return Ok(Value::Number(-1.0)); + } + let n = if args.len() > 1 { to_integer(interp, &arg(args, 1))? } else { len - 1.0 }; + let from = if n >= 0.0 { n.min(len - 1.0) } else { len + n }; + if from < 0.0 { + return Ok(Value::Number(-1.0)); + } + let target = arg(args, 0); + let mut it = Indices::down(interp, o, 0, from as u64 + 1)?; + while let Some(k) = it.next(interp)? { + if let Some(v) = element(interp, o, k)? + && strict_eq(&v, &target) + { + return Ok(number(k)); + } + } + Ok(Value::Number(-1.0)) + }) +} + +// ---- iteration ---- + +/// What an iteration method does with each callback result. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Each { + Every, + Some, + /// `forEach`. + Visit, + Map, + Filter, +} + +/// `every`, `some`, `forEach`, `map` and `filter` (sections 15.4.4.16 to 15.4.4.20). +fn iterate(interp: &mut Interp, this: Value, args: &[Value], kind: Each) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = length_of(interp, o)?; + let callback = arg(args, 0); + require_callable(interp, &callback, "the callback")?; + let this_arg = arg(args, 1); + let a = match kind { + Each::Map => Some(new_array(interp, len)?), + Each::Filter => Some(new_array(interp, 0)?), + _ => None, + }; + let roots: Vec = a.map(Value::Object).into_iter().collect(); + interp.rooted(&roots, |interp| { + let mut to = 0u64; + let mut it = Indices::up(interp, o, 0, u64::from(len))?; + while let Some(k) = it.next(interp)? { + let Some(v) = element(interp, o, k)? else { continue }; + let r = interp.call(&callback, &this_arg, &[v.clone(), number(k), Value::Object(o)])?; + match (kind, a) { + (Each::Every, _) if !r.truthy() => return Ok(Value::Bool(false)), + (Each::Some, _) if r.truthy() => return Ok(Value::Bool(true)), + (Each::Map, Some(a)) => create_at(interp, a, k as f64, r)?, + (Each::Filter, Some(a)) if r.truthy() => { + create_at(interp, a, to as f64, v)?; + to += 1; + } + _ => {} + } + } + Ok(match (kind, a) { + (Each::Every, _) => Value::Bool(true), + (Each::Some, _) => Value::Bool(false), + (_, Some(a)) => Value::Object(a), + _ => Value::Undefined, + }) + }) + }) +} + +fn every(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Every) +} + +fn some(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Some) +} + +fn for_each(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Visit) +} + +fn map(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Map) +} + +fn filter(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + iterate(interp, this, args, Each::Filter) +} + +/// `reduce` and `reduceRight` (sections 15.4.4.21 and 15.4.4.22). +fn fold(interp: &mut Interp, this: Value, args: &[Value], right: bool) -> JsResult { + with_this(interp, &this, |interp, o| { + let len = u64::from(length_of(interp, o)?); + let callback = arg(args, 0); + require_callable(interp, &callback, "the callback")?; + let mut it = Indices::new(interp, o, 0, len, !right, vec![Map::Shift(0)])?; + let slot = interp.temp_roots.len(); + interp.temp_roots.push(Value::Undefined); + // The accumulator is kept in a root slot while the elements are read. + let keep = |interp: &mut Interp, acc: &Value| { + if let Some(s) = interp.temp_roots.get_mut(slot) { + *s = acc.clone(); + } + }; + let r = (|| { + let mut acc = match args.get(1) { + Some(v) => v.clone(), + None => loop { + let Some(k) = it.next(interp)? else { return Err(interp.type_error("reduce of an empty array with no initial value")) }; + if let Some(v) = element(interp, o, k)? { + break v; + } + }, + }; + keep(interp, &acc); + while let Some(k) = it.next(interp)? { + let Some(v) = element(interp, o, k)? else { continue }; + acc = interp.call(&callback, &Value::Undefined, &[acc, v, number(k), Value::Object(o)])?; + keep(interp, &acc); + } + Ok(acc) + })(); + interp.temp_roots.truncate(slot); + r + }) +} + +fn reduce(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + fold(interp, this, args, false) +} + +fn reduce_right(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { + fold(interp, this, args, true) +} diff --git a/crates/js/src/builtins/global.rs b/crates/js/src/builtins/global.rs new file mode 100644 index 000000000..1732ce3f6 --- /dev/null +++ b/crates/js/src/builtins/global.rs @@ -0,0 +1,339 @@ +//! The global functions (ECMA-262 5.1 section 15.1.2 and 15.1.3): `parseInt`, `parseFloat`, +//! `isNaN`, `isFinite` and the URI functions, and Annex B's `escape` and `unescape` (sections +//! B.2.1 and B.2.2). Each code unit read takes a unit of fuel, and the strings they build are +//! checked against the heap limit as they grow. + +use super::{Builder, arg, method}; +use crate::heap::NativeFn; +use crate::interp::{ErrorType, Interp, JsResult}; +use crate::value::{Value, is_str_space, to_int32}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + let global = interp.global; + let functions: [(&'static str, NativeFn, u32); 10] = [ + ("parseInt", parse_int, 2), + ("parseFloat", parse_float, 1), + ("isNaN", is_nan, 1), + ("isFinite", is_finite, 1), + ("decodeURI", decode_uri, 1), + ("decodeURIComponent", decode_uri_component, 1), + ("encodeURI", encode_uri, 1), + ("encodeURIComponent", encode_uri_component, 1), + ("escape", escape, 1), + ("unescape", unescape, 1), + ]; + for (name, f, length) in functions { + method(interp, global, name, f, length)?; + } + Ok(()) +} + +/// `s` after its leading white space and line terminators. +fn trim_start(s: &[u16]) -> &[u16] { + s.get(s.iter().position(|&u| !is_str_space(u)).unwrap_or(s.len())..).unwrap_or_default() +} + +/// ToString of the first argument, as code units, with each one taking a unit of fuel. +fn units_of(interp: &mut Interp, args: &[Value]) -> JsResult> { + let s = interp.to_str(&arg(args, 0))?.flat(); + for _ in 0..s.len() { + interp.use_fuel()?; + } + Ok(s) +} + +fn digit_value(u: u16) -> Option { + char::from_u32(u32::from(u)).and_then(|c| c.to_digit(36)) +} + +/// `parseInt` (section 15.1.2.2). +fn parse_int(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let radix = to_int32(interp.to_number(&arg(args, 1))?); + let mut rest = trim_start(&s); + let sign = match rest.first() { + Some(&u) if u == u16::from(b'-') => -1.0, + _ => 1.0, + }; + if matches!(rest.first(), Some(&u) if u == u16::from(b'-') || u == u16::from(b'+')) { + rest = rest.get(1..).unwrap_or_default(); + } + let mut r = radix as u32; + let mut strip_prefix = true; + if radix != 0 { + if !(2..=36).contains(&radix) { + return Ok(Value::Number(f64::NAN)); + } + strip_prefix = radix == 16; + } else { + r = 10; + } + if strip_prefix + && rest.len() >= 2 + && rest.first() == Some(&u16::from(b'0')) + && matches!(rest.get(1), Some(&u) if u == u16::from(b'x') || u == u16::from(b'X')) + { + rest = rest.get(2..).unwrap_or_default(); + r = 16; + } + let digits: Vec = rest.iter().map_while(|&u| digit_value(u).filter(|&d| d < r)).collect(); + if digits.is_empty() { + return Ok(Value::Number(f64::NAN)); + } + let value = match r { + 10 => digits.iter().filter_map(|&d| char::from_digit(d, 10)).collect::().parse::().unwrap_or(f64::NAN), + 2 | 4 | 8 | 16 | 32 => crate::chars::radix_value(digits.iter().copied(), r).unwrap_or(f64::NAN), + // Other radices may be approximated (section 15.1.2.2, step 13). + _ => digits.iter().fold(0.0, |n, &d| n * f64::from(r) + f64::from(d)), + }; + Ok(Value::Number(sign * value)) +} + +/// The length of the longest prefix of `s` that is a StrDecimalLiteral without its sign and +/// without `Infinity`. +fn decimal_prefix(s: &[u16]) -> usize { + let is_digit = |i: usize| s.get(i).is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); + let mut i = 0; + while is_digit(i) { + i += 1; + } + let mut mantissa = i; + if s.get(i) == Some(&u16::from(b'.')) { + let mut j = i + 1; + while is_digit(j) { + j += 1; + } + mantissa += j - i - 1; + if mantissa > 0 { + i = j; + } + } + if mantissa == 0 { + return 0; + } + if matches!(s.get(i), Some(&u) if u == u16::from(b'e') || u == u16::from(b'E')) { + let mut j = i + 1; + if matches!(s.get(j), Some(&u) if u == u16::from(b'+') || u == u16::from(b'-')) { + j += 1; + } + let digits_start = j; + while is_digit(j) { + j += 1; + } + if j > digits_start { + i = j; + } + } + i +} + +/// `parseFloat` (section 15.1.2.3). +fn parse_float(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut rest = trim_start(&s); + let mut sign = 1.0; + if let Some(&u) = rest.first() + && (u == u16::from(b'-') || u == u16::from(b'+')) + { + if u == u16::from(b'-') { + sign = -1.0; + } + rest = rest.get(1..).unwrap_or_default(); + } + let infinity: Vec = "Infinity".encode_utf16().collect(); + if rest.starts_with(&infinity) { + return Ok(Value::Number(sign * f64::INFINITY)); + } + let n = decimal_prefix(rest); + if n == 0 { + return Ok(Value::Number(f64::NAN)); + } + let text = String::from_utf16_lossy(rest.get(..n).unwrap_or_default()); + Ok(Value::Number(sign * text.parse::().unwrap_or(f64::NAN))) +} + +/// `isNaN` (section 15.1.2.4). +fn is_nan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_nan())) +} + +/// `isFinite` (section 15.1.2.5). +fn is_finite(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + Ok(Value::Bool(interp.to_number(&arg(args, 0))?.is_finite())) +} + +// ---- URIs ---- + +const URI_RESERVED: &str = ";/?:@&=+$,"; +const URI_MARK: &str = "-_.!~*'()"; + +fn in_set(u: u16, extra: &str) -> bool { + u < 0x80 && extra.as_bytes().contains(&(u as u8)) +} + +fn is_unreserved(u: u16) -> bool { + u < 0x80 && ((u as u8).is_ascii_alphanumeric() || in_set(u, URI_MARK)) +} + +fn uri_error(interp: &mut Interp, why: &str) -> crate::interp::Abrupt { + interp.throw(ErrorType::Uri, why) +} + +const HEX: &[u8; 16] = b"0123456789ABCDEF"; + +fn push_byte(out: &mut Builder, interp: &mut Interp, b: u8) -> JsResult<()> { + let units = [u16::from(b'%'), u16::from(HEX[usize::from(b >> 4)]), u16::from(HEX[usize::from(b & 15)])]; + out.push_units(interp, &units) +} + +/// Encode (section 15.1.3): every code unit outside the unescaped set as the `%XX` escapes of +/// its UTF-8 bytes. +fn encode(interp: &mut Interp, args: &[Value], unescaped: fn(u16) -> bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + while let Some(&c) = s.get(k) { + if unescaped(c) { + out.push_units(interp, &[c])?; + k += 1; + continue; + } + let v = match c { + 0xDC00..=0xDFFF => return Err(uri_error(interp, "a lone trailing surrogate cannot be encoded")), + 0xD800..=0xDBFF => match s.get(k + 1) { + Some(&d) if (0xDC00..=0xDFFF).contains(&d) => { + k += 1; + (u32::from(c) - 0xD800) * 0x400 + (u32::from(d) - 0xDC00) + 0x10000 + } + _ => return Err(uri_error(interp, "a lone leading surrogate cannot be encoded")), + }, + _ => u32::from(c), + }; + let mut buf = [0u8; 4]; + let Some(ch) = char::from_u32(v) else { return Err(uri_error(interp, "the string holds a code point that cannot be encoded")) }; + for &b in ch.encode_utf8(&mut buf).as_bytes() { + push_byte(&mut out, interp, b)?; + } + k += 1; + } + Ok(out.finish()) +} + +fn hex_byte(s: &[u16], at: usize) -> Option { + let hi = crate::chars::hex_value(u32::from(*s.get(at)?))?; + let lo = crate::chars::hex_value(u32::from(*s.get(at + 1)?))?; + u8::try_from(hi * 16 + lo).ok() +} + +/// Decode (section 15.1.3): each `%XX` escape sequence of a UTF-8 character as the character, +/// except an escaped character in `reserved`, which stays escaped. +fn decode(interp: &mut Interp, args: &[Value], reserved: &str) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + while let Some(&c) = s.get(k) { + if c != u16::from(b'%') { + out.push_units(interp, &[c])?; + k += 1; + continue; + } + let start = k; + let Some(b) = hex_byte(&s, k + 1) else { return Err(uri_error(interp, "a % is not followed by two hexadecimal digits")) }; + k += 2; + if b & 0x80 == 0 { + let c = u16::from(b); + if in_set(c, reserved) { + out.push_units(interp, s.get(start..=k).unwrap_or_default())?; + } else { + out.push_units(interp, &[c])?; + } + k += 1; + continue; + } + let n = b.leading_ones() as usize; + if n == 1 || n > 4 { + return Err(uri_error(interp, "an escape sequence is not the start of a UTF-8 character")); + } + let mut octets = vec![b]; + for _ in 1..n { + k += 1; + let next = if s.get(k) == Some(&u16::from(b'%')) { hex_byte(&s, k + 1) } else { None }; + let Some(b) = next.filter(|b| b & 0xC0 == 0x80) else { return Err(uri_error(interp, "an escaped UTF-8 character is incomplete")) }; + k += 2; + octets.push(b); + } + let Some(ch) = std::str::from_utf8(&octets).ok().and_then(|t| t.chars().next()) else { + return Err(uri_error(interp, "an escape sequence is not valid UTF-8")); + }; + let mut units = [0u16; 2]; + out.push_units(interp, ch.encode_utf16(&mut units))?; + k += 1; + } + Ok(out.finish()) +} + +fn encode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + encode(interp, args, |u| is_unreserved(u) || in_set(u, URI_RESERVED) || u == u16::from(b'#')) +} + +fn encode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + encode(interp, args, is_unreserved) +} + +fn decode_uri(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + decode(interp, args, ";/?:@&=+$,#") +} + +fn decode_uri_component(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + decode(interp, args, "") +} + +// ---- Annex B ---- + +/// `escape` (section B.2.1). +fn escape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + for &u in s.iter() { + if u < 0x80 && ((u as u8).is_ascii_alphanumeric() || b"@*_+-./".contains(&(u as u8))) { + out.push_units(interp, &[u])?; + } else if u < 256 { + push_byte(&mut out, interp, u as u8)?; + } else { + let hex = |shift: u16| u16::from(HEX[usize::from((u >> shift) & 15)]); + out.push_units(interp, &[u16::from(b'%'), u16::from(b'u'), hex(12), hex(8), hex(4), hex(0)])?; + } + } + Ok(out.finish()) +} + +/// `unescape` (section B.2.2). +fn unescape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let s = units_of(interp, args)?; + let mut out = Builder::new(); + let mut k = 0; + let hex4 = |at: usize| -> Option { + let mut v = 0u16; + for i in 0..4 { + v = v * 16 + u16::try_from(crate::chars::hex_value(u32::from(*s.get(at + i)?))?).ok()?; + } + Some(v) + }; + while let Some(&c) = s.get(k) { + let mut unit = c; + if c == u16::from(b'%') { + if s.get(k + 1) == Some(&u16::from(b'u')) + && let Some(v) = hex4(k + 2) + { + unit = v; + k += 5; + } else if let Some(b) = hex_byte(&s, k + 1) { + unit = u16::from(b); + k += 2; + } + } + out.push_units(interp, &[unit])?; + k += 1; + } + Ok(out.finish()) +} diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs new file mode 100644 index 000000000..9acf2a6da --- /dev/null +++ b/crates/js/src/builtins/json.rs @@ -0,0 +1,677 @@ +//! The `JSON` object (ECMA-262 5.1 section 15.12): `parse` with a reviver and `stringify` with a +//! replacer function or property list and indentation. +//! +//! Both work without recursion. `parse` reads nested arrays and objects with an explicit stack and +//! walks them for the reviver with another, and `stringify` writes nested values from a stack of +//! the objects it is inside, so nesting is limited only by the heap. Each code unit of the text +//! parsed, each property visited and each value written takes a unit of fuel; strings, arrays and +//! objects are checked against the heap limit before they are made, and the text `stringify` +//! writes is checked as it grows. + +use std::collections::HashSet; + +use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; +use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; +use crate::interp::{Desc, ErrorType, Interp, JsResult}; +use crate::value::{Str, Value, number_to_str}; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + interp.reserve_heap(OBJECT_BYTES)?; + let proto = interp.intrinsics.object_proto; + let json = interp.heap.alloc_object(Object::new(Class::Json, Some(proto))); + method(interp, json, "parse", parse, 2)?; + method(interp, json, "stringify", stringify, 3)?; + let global = interp.global; + data(interp, global, "JSON", Value::Object(json), crate::heap::HIDDEN) +} + +// ---- parse ---- + +/// A container being read, with the property name its next value goes under. +enum Open { + Array { obj: ObjId, next: u32 }, + Object { obj: ObjId, name: Option }, +} + +struct Parser<'a> { + s: &'a [u16], + pos: usize, +} + +const QUOTE: u16 = b'"' as u16; + +impl Parser<'_> { + fn peek(&self) -> Option { + self.s.get(self.pos).copied() + } + + fn skip_space(&mut self) { + while matches!(self.peek(), Some(0x09 | 0x0A | 0x0D | 0x20)) { + self.pos += 1; + } + } + + fn eat(&mut self, c: u8) -> bool { + if self.peek() == Some(u16::from(c)) { + self.pos += 1; + return true; + } + false + } + + fn word(&mut self, w: &str) -> bool { + let units: Vec = w.encode_utf16().collect(); + if self.s.get(self.pos..).is_some_and(|rest| rest.starts_with(&units)) { + self.pos += units.len(); + return true; + } + false + } + + /// A JSONString's characters (section 15.12.1.1), after its opening quote. + fn string(&mut self) -> Result, &'static str> { + let mut out = Vec::new(); + loop { + let Some(c) = self.peek() else { return Err("a string is not closed") }; + self.pos += 1; + match c { + QUOTE => return Ok(out), + 0x5C => { + let Some(e) = self.peek() else { return Err("a string is not closed") }; + self.pos += 1; + let unit = match u8::try_from(e).unwrap_or(0) { + b'"' => QUOTE, + b'\\' => 0x5C, + b'/' => 0x2F, + b'b' => 0x08, + b'f' => 0x0C, + b'n' => 0x0A, + b'r' => 0x0D, + b't' => 0x09, + b'u' => { + let mut v = 0u32; + for _ in 0..4 { + let Some(d) = self.peek().and_then(|u| crate::chars::hex_value(u32::from(u))) else { + return Err("a \\u escape needs four hexadecimal digits"); + }; + self.pos += 1; + v = v * 16 + d; + } + u16::try_from(v).unwrap_or(0) + } + _ => return Err("a string holds an escape sequence JSON does not have"), + }; + out.push(unit); + } + 0..=0x1F => return Err("a string holds a control character"), + _ => out.push(c), + } + } + } + + /// A JSONNumber (section 15.12.1.1). + fn number(&mut self) -> Result { + let start = self.pos; + self.eat(b'-'); + let digit = |p: &Self| p.peek().is_some_and(|u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); + if self.eat(b'0') { + } else if digit(self) { + while digit(self) { + self.pos += 1; + } + } else { + return Err("a number has no digits"); + } + if self.eat(b'.') { + if !digit(self) { + return Err("a number's fraction has no digits"); + } + while digit(self) { + self.pos += 1; + } + } + if self.eat(b'e') || self.eat(b'E') { + if !self.eat(b'+') { + self.eat(b'-'); + } + if !digit(self) { + return Err("a number's exponent has no digits"); + } + while digit(self) { + self.pos += 1; + } + } + let text = String::from_utf16_lossy(self.s.get(start..self.pos).unwrap_or_default()); + text.parse::().map_err(|_| "a number cannot be read") + } +} + +/// What the parser read at a value position. +enum Token { + Value(Value), + OpenArray, + OpenObject, +} + +fn syntax(interp: &mut Interp, why: &str, at: usize) -> crate::interp::Abrupt { + interp.throw(ErrorType::Syntax, &format!("JSON.parse: {why} (at offset {at})")) +} + +/// `JSON.parse` (section 15.12.2). +fn parse(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let text = interp.to_str(&arg(args, 0))?.flat(); + for _ in 0..text.len() { + interp.use_fuel()?; + } + let mark = interp.temp_roots.len(); + let r = parse_text(interp, &text).and_then(|v| { + let reviver = arg(args, 1); + if !interp.is_callable(&reviver) { + return Ok(v); + } + interp.temp_roots.push(v.clone()); + revive(interp, v, &reviver) + }); + interp.temp_roots.truncate(mark); + r +} + +/// Reads a value at the parser's position: a primitive, or the start of an array or object. +fn token(interp: &mut Interp, p: &mut Parser<'_>) -> JsResult { + p.skip_space(); + let at = p.pos; + let t = match p.peek() { + Some(QUOTE) => { + p.pos += 1; + let units = p.string().map_err(|why| syntax(interp, why, at))?; + Token::Value(new_string(interp, units)?) + } + Some(0x5B) => { + p.pos += 1; + Token::OpenArray + } + Some(0x7B) => { + p.pos += 1; + Token::OpenObject + } + _ if p.word("true") => Token::Value(Value::Bool(true)), + _ if p.word("false") => Token::Value(Value::Bool(false)), + _ if p.word("null") => Token::Value(Value::Null), + Some(u) if u == u16::from(b'-') || (u16::from(b'0')..=u16::from(b'9')).contains(&u) => { + Token::Value(Value::Number(p.number().map_err(|why| syntax(interp, why, at))?)) + } + Some(_) => return Err(syntax(interp, "unexpected character", at)), + None => return Err(syntax(interp, "unexpected end of text", at)), + }; + Ok(t) +} + +/// Reads the property name of an object member and its colon, or `None` at the closing brace +/// of an empty object. +fn member_name(interp: &mut Interp, p: &mut Parser<'_>, first: bool) -> JsResult> { + p.skip_space(); + if first && p.eat(b'}') { + return Ok(None); + } + let at = p.pos; + if !p.eat(b'"') { + return Err(syntax(interp, "a property name must be a string", at)); + } + let units = p.string().map_err(|why| syntax(interp, why, at))?; + interp.reserve_heap(string_bytes(units.len()))?; + p.skip_space(); + if !p.eat(b':') { + return Err(syntax(interp, "a property name must be followed by ':'", p.pos)); + } + Ok(Some(units.into())) +} + +fn parse_text(interp: &mut Interp, text: &[u16]) -> JsResult { + let mut p = Parser { s: text, pos: 0 }; + let mut stack: Vec = Vec::new(); + // The next value to read: the first of an array or object, or one after a comma. + let mut value = token(interp, &mut p)?; + loop { + // Open a container, or place a finished value into the container it belongs to. + let mut done = match value { + Token::OpenArray => { + let a = new_array(interp, 0)?; + interp.temp_roots.push(Value::Object(a)); + p.skip_space(); + if p.eat(b']') { + Some(Value::Object(a)) + } else { + stack.push(Open::Array { obj: a, next: 0 }); + value = token(interp, &mut p)?; + continue; + } + } + Token::OpenObject => { + interp.reserve_heap(OBJECT_BYTES)?; + let o = interp.new_object(); + interp.temp_roots.push(Value::Object(o)); + match member_name(interp, &mut p, true)? { + None => Some(Value::Object(o)), + Some(name) => { + stack.push(Open::Object { obj: o, name: Some(name) }); + value = token(interp, &mut p)?; + continue; + } + } + } + Token::Value(v) => Some(v), + }; + // Close every container that the finished value completes. + while let Some(v) = done.take() { + let Some(top) = stack.last_mut() else { + p.skip_space(); + if p.pos < text.len() { + return Err(syntax(interp, "unexpected text after the value", p.pos)); + } + return Ok(v); + }; + match top { + Open::Array { obj, next } => { + let (obj, i) = (*obj, *next); + *next = next.saturating_add(1); + interp.reserve_heap(ELEMENT_BYTES)?; + interp.define(obj, key_of(f64::from(i)), Prop::Data { value: v, flags: OPEN })?; + p.skip_space(); + if p.eat(b',') { + break; + } + if !p.eat(b']') { + return Err(syntax(interp, "expected ',' or ']'", p.pos)); + } + stack.pop(); + done = Some(Value::Object(obj)); + } + Open::Object { obj, name } => { + let obj = *obj; + let Some(k) = name.take() else { return Err(syntax(interp, "a member has no name", p.pos)) }; + interp.reserve_heap(ELEMENT_BYTES)?; + interp.define(obj, k, Prop::Data { value: v, flags: OPEN })?; + p.skip_space(); + if p.eat(b',') { + let k = member_name(interp, &mut p, false)?; + if let Some(Open::Object { name, .. }) = stack.last_mut() { + *name = k; + } + break; + } + if !p.eat(b'}') { + return Err(syntax(interp, "expected ',' or '}'", p.pos)); + } + stack.pop(); + done = Some(Value::Object(obj)); + } + } + } + value = token(interp, &mut p)?; + } +} + +/// A holder being walked for the reviver (section 15.12.2, Walk). +struct Walk { + holder: ObjId, + name: Key, + val: Value, + keys: Vec, + next: usize, +} + +/// Starts walking `holder[name]`: reads the value and, for an array or object, the names of its +/// elements or own enumerable properties. The value is pushed onto the temporary roots. +fn enter(interp: &mut Interp, holder: ObjId, name: Key) -> JsResult { + let val = interp.get_from(holder, &name, &Value::Object(holder))?; + interp.temp_roots.push(val.clone()); + let mut keys = Vec::new(); + if let Value::Object(o) = val { + if interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })) { + let len = length_of(interp, o)?; + interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?; + for i in 0..len { + interp.use_fuel()?; + keys.push(key_of(f64::from(i))); + } + } else { + for k in interp.own_keys(o)? { + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + keys.push(k); + } + } + } + } + Ok(Walk { holder, name, val, keys, next: 0 }) +} + +/// Walks the parsed value bottom-up, calling the reviver for each property and replacing or +/// deleting the property with what it returns. +fn revive(interp: &mut Interp, v: Value, reviver: &Value) -> JsResult { + interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?; + let root = interp.new_object(); + interp.temp_roots.push(Value::Object(root)); + interp.define(root, key(""), Prop::Data { value: v, flags: OPEN })?; + let mut stack = vec![enter(interp, root, key(""))?]; + let mut ret: Option = None; + loop { + let Some(top) = stack.last_mut() else { return Ok(Value::Undefined) }; + if let (Some(r), Value::Object(o)) = (ret.take(), &top.val) { + let o = *o; + let Some(k) = top.next.checked_sub(1).and_then(|i| top.keys.get(i)).cloned() else { return Ok(Value::Undefined) }; + if matches!(r, Value::Undefined) { + interp.delete(o, &k, false)?; + } else { + interp.reserve_heap(ELEMENT_BYTES)?; + interp.define_own_property(o, k, &Desc::data(r, OPEN), false)?; + } + } + if let (Some(k), Value::Object(o)) = (top.keys.get(top.next).cloned(), &top.val) { + let o = *o; + top.next += 1; + interp.use_fuel()?; + stack.push(enter(interp, o, k)?); + continue; + } + let Some(done) = stack.pop() else { return Ok(Value::Undefined) }; + let name = Value::String(Str::from_rc(done.name)); + let r = interp.call(reviver, &Value::Object(done.holder), &[name, done.val])?; + interp.temp_roots.pop(); + if stack.is_empty() { + return Ok(r); + } + ret = Some(r); + } +} + +// ---- stringify ---- + +/// The settings of one `stringify` call. +struct Settings { + replacer: Option, + properties: Option>, + gap: Vec, +} + +/// A value as `stringify` writes it, after `toJSON`, the replacer and unwrapping. +enum Item { + Skip, + Primitive(Value), + Container(ObjId), +} + +/// An object or array being written. +struct Writing { + obj: ObjId, + keys: Vec, + array: bool, + next: usize, + count: usize, + /// The indentation outside it. + stepback: usize, +} + +struct Writer { + out: Builder, + indent: Vec, + stack: Vec, + open: HashSet, +} + +/// `JSON.stringify` (section 15.12.3). +fn stringify(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let mark = interp.temp_roots.len(); + let r = stringify_rooted(interp, args); + interp.temp_roots.truncate(mark); + r +} + +fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { + let settings = settings(interp, &arg(args, 1), &arg(args, 2))?; + interp.reserve_heap(OBJECT_BYTES + ELEMENT_BYTES)?; + let wrapper = interp.new_object(); + interp.temp_roots.push(Value::Object(wrapper)); + interp.define(wrapper, key(""), Prop::Data { value: arg(args, 0), flags: OPEN })?; + let mut w = Writer { out: Builder::new(), indent: Vec::new(), stack: Vec::new(), open: HashSet::new() }; + match resolve(interp, &settings, wrapper, &key(""))? { + Item::Skip => return Ok(Value::Undefined), + Item::Primitive(v) => write_primitive(interp, &mut w.out, &v)?, + Item::Container(o) => open(interp, &settings, &mut w, o)?, + } + while !w.stack.is_empty() { + step(interp, &settings, &mut w)?; + } + Ok(w.out.finish()) +} + +/// Reads the replacer and the space argument (section 15.12.3, steps 4 to 8). +fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult { + let mut s = Settings { replacer: None, properties: None, gap: Vec::new() }; + if let Value::Object(r) = replacer { + if interp.is_callable(replacer) { + s.replacer = Some(replacer.clone()); + } else if interp.heap.object(*r).is_some_and(|o| matches!(o.class, Class::Array { .. })) { + s.properties = Some(property_list(interp, *r)?); + } + } + let space = match space { + Value::Object(o) => match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Number) => Value::Number(interp.to_number(space)?), + Some(Class::String(_)) => Value::String(interp.to_str(space)?), + _ => space.clone(), + }, + _ => space.clone(), + }; + match space { + Value::Number(_) => { + let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; + s.gap = vec![u16::from(b' '); n]; + } + Value::String(t) => s.gap = t.flat().iter().take(10).copied().collect(), + _ => {} + } + Ok(s) +} + +/// The property names a replacer array lists, without repeats (section 15.12.3, step 4.b). +fn property_list(interp: &mut Interp, r: ObjId) -> JsResult> { + let len = length_of(interp, r)?; + let mut list: Vec = Vec::new(); + let mut seen = HashSet::new(); + for i in 0..len { + interp.use_fuel()?; + let v = interp.get_from(r, &key_of(f64::from(i)), &Value::Object(r))?; + let item = match &v { + Value::String(s) => Some(s.flat()), + Value::Number(n) => Some(number_to_str(*n).flat()), + Value::Object(o) if interp.heap.object(*o).is_some_and(|x| matches!(x.class, Class::String(_) | Class::Number)) => { + Some(interp.to_str(&v)?.flat()) + } + _ => None, + }; + if let Some(k) = item + && seen.insert(k.clone()) + { + interp.reserve_heap(string_bytes(k.len()))?; + list.push(k); + } + } + Ok(list) +} + +/// Steps 1 to 4 and 10 of Str (section 15.12.3): reads `holder[key]`, applies `toJSON` and the +/// replacer, unwraps Number, String and Boolean objects, and leaves out functions and +/// `undefined`. +fn resolve(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key) -> JsResult { + let slot = interp.temp_roots.len(); + interp.temp_roots.push(Value::Undefined); + let r = resolve_rooted(interp, settings, holder, k, slot); + interp.temp_roots.truncate(slot); + r +} + +fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &Key, slot: usize) -> JsResult { + let keep = |interp: &mut Interp, v: &Value| { + if let Some(s) = interp.temp_roots.get_mut(slot) { + *s = v.clone(); + } + }; + let mut v = interp.get_from(holder, k, &Value::Object(holder))?; + keep(interp, &v); + let name = Value::String(Str::from_rc(k.clone())); + if let Value::Object(o) = v { + let to_json = interp.get_from(o, &key("toJSON"), &v)?; + if interp.is_callable(&to_json) { + v = interp.call(&to_json, &v, std::slice::from_ref(&name))?; + keep(interp, &v); + } + } + if let Some(replacer) = &settings.replacer { + v = interp.call(replacer, &Value::Object(holder), &[name, v])?; + keep(interp, &v); + } + if let Value::Object(o) = &v { + v = match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Number) => Value::Number(interp.to_number(&v)?), + Some(Class::String(_)) => Value::String(interp.to_str(&v)?), + Some(Class::Boolean(b)) => Value::Bool(*b), + _ => v, + }; + } + Ok(match v { + Value::Undefined => Item::Skip, + Value::Object(_) if interp.is_callable(&v) => Item::Skip, + Value::Object(o) => Item::Container(o), + other => Item::Primitive(other), + }) +} + +/// Quote (section 15.12.3): the string in double quotes with the characters JSON escapes. +fn write_quoted(interp: &mut Interp, out: &mut Builder, s: &[u16]) -> JsResult<()> { + out.push_units(interp, &[QUOTE])?; + let mut start = 0; + for (i, &c) in s.iter().enumerate() { + let escape: Option<&str> = match c { + QUOTE => Some("\\\""), + 0x5C => Some("\\\\"), + 0x08 => Some("\\b"), + 0x0C => Some("\\f"), + 0x0A => Some("\\n"), + 0x0D => Some("\\r"), + 0x09 => Some("\\t"), + 0..=0x1F => None, + _ => continue, + }; + out.push_units(interp, s.get(start..i).unwrap_or_default())?; + match escape { + Some(e) => out.push_str(interp, e)?, + None => out.push_str(interp, &format!("\\u{c:04x}"))?, + } + start = i + 1; + } + out.push_units(interp, s.get(start..).unwrap_or_default())?; + out.push_units(interp, &[QUOTE]) +} + +fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResult<()> { + match v { + Value::Null => out.push_str(interp, "null"), + Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), + Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), + Value::Number(_) => out.push_str(interp, "null"), + Value::String(s) => write_quoted(interp, out, &s.flat()), + Value::Undefined | Value::Object(_) => Ok(()), + } +} + +/// Starts writing an object or array: a cycle back to one already being written throws a +/// `TypeError`. +fn open(interp: &mut Interp, settings: &Settings, w: &mut Writer, o: ObjId) -> JsResult<()> { + if w.open.contains(&o) { + return Err(interp.type_error("JSON.stringify cannot write a structure that contains itself")); + } + interp.temp_roots.push(Value::Object(o)); + let array = interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })); + let keys = if array { + let len = length_of(interp, o)?; + // Every element is written as at least `null` and a comma, so a length that cannot fit + // is refused before anything is listed. + if u64::from(len).saturating_mul(5) > (interp.limits.heap_bytes / 4) as u64 { + return Err(crate::interp::Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.reserve_heap((len as usize).saturating_mul(crate::heap::PROP_BYTES))?; + let mut keys = Vec::with_capacity(len as usize); + for i in 0..len { + interp.use_fuel()?; + keys.push(key_of(f64::from(i))); + } + keys + } else if let Some(list) = &settings.properties { + list.clone() + } else { + let mut keys = Vec::new(); + for k in interp.own_keys(o)? { + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + keys.push(k); + } + } + keys + }; + w.open.insert(o); + let stepback = w.indent.len(); + w.indent.extend_from_slice(&settings.gap); + w.out.push_str(interp, if array { "[" } else { "{" })?; + w.stack.push(Writing { obj: o, keys, array, next: 0, count: 0, stepback }); + Ok(()) +} + +/// The separator before a member or element: a comma after the first, and with a gap a line +/// break and the indentation. +fn separator(interp: &mut Interp, w: &mut Writer, first: bool, gap: bool) -> JsResult<()> { + if !first { + w.out.push_str(interp, ",")?; + } + if gap { + w.out.push_str(interp, "\n")?; + w.out.push_units(interp, &w.indent.clone())?; + } + Ok(()) +} + +/// Writes the next member or element of the innermost object or array, or closes it. +fn step(interp: &mut Interp, settings: &Settings, w: &mut Writer) -> JsResult<()> { + let gap = !settings.gap.is_empty(); + let Some(top) = w.stack.last_mut() else { return Ok(()) }; + let (obj, array, count) = (top.obj, top.array, top.count); + let Some(k) = top.keys.get(top.next).cloned() else { + let stepback = top.stepback; + w.stack.pop(); + w.open.remove(&obj); + interp.temp_roots.pop(); + w.indent.truncate(stepback); + if count > 0 && gap { + w.out.push_str(interp, "\n")?; + w.out.push_units(interp, &w.indent.clone())?; + } + return w.out.push_str(interp, if array { "]" } else { "}" }); + }; + top.next += 1; + interp.use_fuel()?; + let item = resolve(interp, settings, obj, &k)?; + if !array && matches!(item, Item::Skip) { + return Ok(()); + } + if let Some(top) = w.stack.last_mut() { + top.count += 1; + } + separator(interp, w, count == 0, gap)?; + if !array { + write_quoted(interp, &mut w.out, &k)?; + w.out.push_str(interp, if gap { ": " } else { ":" })?; + } + match item { + Item::Skip => w.out.push_str(interp, "null"), + Item::Primitive(v) => write_primitive(interp, &mut w.out, &v), + Item::Container(o) => open(interp, settings, w, o), + } +} diff --git a/crates/js/src/builtins/math.rs b/crates/js/src/builtins/math.rs new file mode 100644 index 000000000..3ad9421ed --- /dev/null +++ b/crates/js/src/builtins/math.rs @@ -0,0 +1,209 @@ +//! The `Math` object (ECMA-262 5.1 section 15.8): its constants and functions, with the special +//! cases the section lists for NaN, signed zeros and infinities. + +use super::{arg, data, method}; +use crate::heap::{Class, NativeFn, OBJECT_BYTES, Object}; +use crate::interp::{Interp, JsResult}; +use crate::value::Value; + +pub(super) fn install(interp: &mut Interp) -> JsResult<()> { + interp.reserve_heap(OBJECT_BYTES)?; + let proto = interp.intrinsics.object_proto; + let math = interp.heap.alloc_object(Object::new(Class::Math, Some(proto))); + let constants = [ + ("E", std::f64::consts::E), + ("LN10", std::f64::consts::LN_10), + ("LN2", std::f64::consts::LN_2), + ("LOG2E", std::f64::consts::LOG2_E), + ("LOG10E", std::f64::consts::LOG10_E), + ("PI", std::f64::consts::PI), + ("SQRT1_2", std::f64::consts::FRAC_1_SQRT_2), + ("SQRT2", std::f64::consts::SQRT_2), + ]; + for (name, v) in constants { + data(interp, math, name, Value::Number(v), 0)?; + } + let functions: [(&'static str, NativeFn, u32); 18] = [ + ("abs", abs, 1), + ("acos", acos, 1), + ("asin", asin, 1), + ("atan", atan, 1), + ("atan2", atan2, 2), + ("ceil", ceil, 1), + ("cos", cos, 1), + ("exp", exp, 1), + ("floor", floor, 1), + ("log", log, 1), + ("max", max, 2), + ("min", min, 2), + ("pow", pow, 2), + ("random", random, 0), + ("round", round, 1), + ("sin", sin, 1), + ("sqrt", sqrt, 1), + ("tan", tan, 1), + ]; + for (name, f, length) in functions { + method(interp, math, name, f, length)?; + } + let global = interp.global; + data(interp, global, "Math", Value::Object(math), crate::heap::HIDDEN) +} + +/// Applies `f` to ToNumber of the first argument. +fn unary(interp: &mut Interp, args: &[Value], f: fn(f64) -> f64) -> JsResult { + let x = interp.to_number(&arg(args, 0))?; + Ok(Value::Number(f(x))) +} + +fn abs(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::abs) +} + +fn acos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::acos) +} + +fn asin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::asin) +} + +fn atan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::atan) +} + +fn ceil(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::ceil) +} + +fn cos(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::cos) +} + +fn exp(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::exp) +} + +fn floor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::floor) +} + +fn log(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::ln) +} + +fn sin(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::sin) +} + +fn sqrt(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::sqrt) +} + +fn tan(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, f64::tan) +} + +/// `Math.round` (section 15.8.2.15): the nearest integer, halves rounding up, with -0 for +/// arguments from -0.5 to -0. +fn round_number(x: f64) -> f64 { + if !x.is_finite() || x == 0.0 { + return x; + } + if (-0.5..0.0).contains(&x) { + return -0.0; + } + let floor = x.floor(); + // `x - floor` is exact, so a value just below a half (0.49999999999999994) does not round + // up the way `floor(x + 0.5)` would. + if x - floor >= 0.5 { floor + 1.0 } else { floor } +} + +fn round(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + unary(interp, args, round_number) +} + +/// `Math.atan2` (section 15.8.2.5). +fn atan2(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let y = interp.to_number(&arg(args, 0))?; + let x = interp.to_number(&arg(args, 1))?; + Ok(Value::Number(y.atan2(x))) +} + +/// `Math.pow` (section 15.8.2.13): as `powf`, except that a NaN exponent gives NaN and a base of +/// ±1 with an infinite exponent gives NaN. +fn pow_number(x: f64, y: f64) -> f64 { + if y.is_nan() || (x.abs() == 1.0 && y.is_infinite()) { + return f64::NAN; + } + x.powf(y) +} + +fn pow(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let x = interp.to_number(&arg(args, 0))?; + let y = interp.to_number(&arg(args, 1))?; + Ok(Value::Number(pow_number(x, y))) +} + +/// `Math.max` and `Math.min` (sections 15.8.2.11 and 15.8.2.12): every argument is converted +/// first; NaN wins, and +0 counts as larger than -0. +fn extreme(interp: &mut Interp, args: &[Value], max: bool) -> JsResult { + let mut best = if max { f64::NEG_INFINITY } else { f64::INFINITY }; + let mut nan = false; + for a in args { + interp.use_fuel()?; + let x = interp.to_number(a)?; + if x.is_nan() { + nan = true; + continue; + } + let better = if x == best { + if max { best.is_sign_negative() && x.is_sign_positive() } else { best.is_sign_positive() && x.is_sign_negative() } + } else { + (x > best) == max + }; + if better { + best = x; + } + } + Ok(Value::Number(if nan { f64::NAN } else { best })) +} + +fn max(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + extreme(interp, args, true) +} + +fn min(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + extreme(interp, args, false) +} + +/// `Math.random` (section 15.8.2.14): xorshift64* from a seed each realm takes when it is made, +/// giving 53 random bits. +fn random(interp: &mut Interp, _: Value, _: &[Value], _: bool) -> JsResult { + let mut s = interp.random; + s ^= s >> 12; + s ^= s << 25; + s ^= s >> 27; + interp.random = s; + let bits = s.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 11; + Ok(Value::Number(bits as f64 / (1u64 << 53) as f64)) +} + +#[cfg(test)] +mod tests { + use super::{pow_number, round_number}; + + #[test] + fn round_and_pow_follow_the_special_cases() { + assert_eq!(round_number(0.499_999_999_999_999_94), 0.0); + assert_eq!(round_number(2.5), 3.0); + assert_eq!(round_number(-2.5), -2.0); + assert!(round_number(-0.5).is_sign_negative()); + assert!(round_number(-0.0).is_sign_negative()); + assert_eq!(round_number(4_503_599_627_370_495.5), 4_503_599_627_370_496.0); + assert!(pow_number(1.0, f64::NAN).is_nan()); + assert!(pow_number(-1.0, f64::INFINITY).is_nan()); + assert_eq!(pow_number(f64::NAN, 0.0), 1.0); + assert_eq!(pow_number(2.0, 10.0), 1024.0); + } +} diff --git a/crates/js/src/builtins/object.rs b/crates/js/src/builtins/object.rs new file mode 100644 index 000000000..969a9c609 --- /dev/null +++ b/crates/js/src/builtins/object.rs @@ -0,0 +1,302 @@ +//! `Object`'s functions (ECMA-262 5.1 section 15.2.3). A primitive given where an object is +//! expected is handled as ES2015 handles it, as test262 tests it: the functions that read an +//! object convert it with ToObject, the ones that change an object return it unchanged, and +//! `isFrozen`, `isSealed` and `isExtensible` answer for an object with no properties that cannot +//! be extended. + +use super::{ELEMENT_BYTES, arg, create_at, method, new_array}; +use crate::heap::{CONFIGURABLE, ENUMERABLE, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, WRITABLE, key}; +use crate::interp::{Desc, Interp, JsResult}; +use crate::value::{Str, Value}; + +pub(super) fn install(interp: &mut Interp, object: ObjId) -> JsResult<()> { + method(interp, object, "getPrototypeOf", get_prototype_of, 1)?; + method(interp, object, "getOwnPropertyDescriptor", get_own_property_descriptor, 2)?; + method(interp, object, "getOwnPropertyNames", get_own_property_names, 1)?; + method(interp, object, "create", create, 2)?; + method(interp, object, "defineProperty", define_property, 3)?; + method(interp, object, "defineProperties", define_properties, 2)?; + method(interp, object, "seal", seal, 1)?; + method(interp, object, "freeze", freeze, 1)?; + method(interp, object, "preventExtensions", prevent_extensions, 1)?; + method(interp, object, "isSealed", is_sealed, 1)?; + method(interp, object, "isFrozen", is_frozen, 1)?; + method(interp, object, "isExtensible", is_extensible, 1)?; + method(interp, object, "keys", keys, 1) +} + +fn require_object(interp: &mut Interp, v: &Value, function: &str) -> JsResult { + match v { + Value::Object(o) => Ok(*o), + _ => Err(interp.type_error(&format!("Object.{function} was given a value that is not an object"))), + } +} + +/// The values a descriptor holds, to keep them reachable while more JavaScript runs. +fn desc_values(desc: &Desc) -> Vec { + let mut out: Vec = desc.value.iter().cloned().collect(); + out.extend(desc.get.flatten().map(Value::Object)); + out.extend(desc.set.flatten().map(Value::Object)); + out +} + +/// ToPropertyDescriptor (section 8.10.5). +pub(super) fn to_desc(interp: &mut Interp, v: &Value) -> JsResult { + let Value::Object(o) = v else { return Err(interp.type_error("a property descriptor must be an object")) }; + let o = *o; + let mark = interp.temp_roots.len(); + let r = read_desc(interp, o); + interp.temp_roots.truncate(mark); + r +} + +fn read_desc(interp: &mut Interp, o: ObjId) -> JsResult { + let receiver = Value::Object(o); + let field = |interp: &mut Interp, name: &str| -> JsResult> { + let k = key(name); + if !interp.has_property(o, &k)? { + return Ok(None); + } + let v = interp.get_from(o, &k, &receiver)?; + interp.temp_roots.push(v.clone()); + Ok(Some(v)) + }; + let mut desc = Desc { + enumerable: field(interp, "enumerable")?.map(|v| v.truthy()), + configurable: field(interp, "configurable")?.map(|v| v.truthy()), + value: field(interp, "value")?, + writable: field(interp, "writable")?.map(|v| v.truthy()), + ..Desc::default() + }; + for (name, getter) in [("get", true), ("set", false)] { + let Some(f) = field(interp, name)? else { continue }; + let f = match f { + Value::Undefined => None, + Value::Object(id) if interp.is_callable(&f) => Some(id), + _ => return Err(interp.type_error(&format!("a property's {name} must be a function or undefined"))), + }; + if getter { + desc.get = Some(f); + } else { + desc.set = Some(f); + } + } + if desc.is_accessor() && desc.is_data() { + return Err(interp.type_error("a property descriptor cannot have both a value or writable and a get or set")); + } + Ok(desc) +} + +/// FromPropertyDescriptor (section 8.10.4). +fn from_prop(interp: &mut Interp, prop: &Prop) -> JsResult { + interp.reserve_heap(OBJECT_BYTES + 4 * ELEMENT_BYTES)?; + let o = interp.new_object(); + let flags = prop.flags(); + match prop { + Prop::Data { value, .. } => { + interp.define(o, key("value"), Prop::Data { value: value.clone(), flags: OPEN })?; + interp.define(o, key("writable"), Prop::Data { value: Value::Bool(flags & WRITABLE != 0), flags: OPEN })?; + } + Prop::Accessor { get, set, .. } => { + interp.define(o, key("get"), Prop::Data { value: get.map_or(Value::Undefined, Value::Object), flags: OPEN })?; + interp.define(o, key("set"), Prop::Data { value: set.map_or(Value::Undefined, Value::Object), flags: OPEN })?; + } + } + interp.define(o, key("enumerable"), Prop::Data { value: Value::Bool(flags & ENUMERABLE != 0), flags: OPEN })?; + interp.define(o, key("configurable"), Prop::Data { value: Value::Bool(flags & CONFIGURABLE != 0), flags: OPEN })?; + Ok(Value::Object(o)) +} + +/// An array of the names, kept reachable while it is filled. +fn names_array(interp: &mut Interp, names: Vec) -> JsResult { + let a = new_array(interp, 0)?; + interp.rooted(&[Value::Object(a)], |interp| { + for (i, k) in names.into_iter().enumerate() { + interp.use_fuel()?; + create_at(interp, a, i as f64, Value::String(Str::from_rc(k)))?; + } + Ok(Value::Object(a)) + }) +} + +/// `Object.getPrototypeOf` (section 15.2.3.2). +fn get_prototype_of(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + Ok(interp.heap.object(o).and_then(|o| o.proto).map_or(Value::Null, Value::Object)) +} + +/// `Object.getOwnPropertyDescriptor` (section 15.2.3.3). +fn get_own_property_descriptor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let k = interp.to_key(&arg(args, 1))?; + match interp.get_own_property(o, &k)? { + Some(p) => from_prop(interp, &p), + None => Ok(Value::Undefined), + } + }) +} + +/// `Object.getOwnPropertyNames` (section 15.2.3.4). +fn get_own_property_names(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let names = interp.own_keys(o)?; + names_array(interp, names) + }) +} + +/// `Object.create` (section 15.2.3.5). +fn create(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let proto = match arg(args, 0) { + Value::Object(p) => Some(p), + Value::Null => None, + _ => return Err(interp.type_error("Object.create's prototype must be an object or null")), + }; + interp.reserve_heap(OBJECT_BYTES)?; + let o = interp.heap.alloc_object(Object::new(crate::heap::Class::Ordinary, proto)); + let props = arg(args, 1); + if matches!(props, Value::Undefined) { + return Ok(Value::Object(o)); + } + interp.rooted(&[Value::Object(o)], |interp| define_all(interp, o, &props))?; + Ok(Value::Object(o)) +} + +/// `Object.defineProperty` (section 15.2.3.6). +fn define_property(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = require_object(interp, &arg(args, 0), "defineProperty")?; + let k = interp.to_key(&arg(args, 1))?; + let desc = to_desc(interp, &arg(args, 2))?; + let roots = desc_values(&desc); + interp.rooted(&roots, |interp| interp.define_own_property(o, k, &desc, true))?; + Ok(Value::Object(o)) +} + +/// `Object.defineProperties` (section 15.2.3.7). +fn define_properties(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = require_object(interp, &arg(args, 0), "defineProperties")?; + define_all(interp, o, &arg(args, 1))?; + Ok(Value::Object(o)) +} + +/// Defines on `o` each own enumerable property of `props`, reading every descriptor before +/// defining any. +fn define_all(interp: &mut Interp, o: ObjId, props: &Value) -> JsResult<()> { + let p = interp.to_object(props)?; + let mark = interp.temp_roots.len(); + interp.temp_roots.push(Value::Object(p)); + let r = (|| { + let mut descs = Vec::new(); + for k in interp.own_keys(p)? { + interp.use_fuel()?; + if !interp.get_own_property(p, &k)?.is_some_and(|prop| prop.enumerable()) { + continue; + } + let v = interp.get_from(p, &k, &Value::Object(p))?; + interp.temp_roots.push(v.clone()); + let desc = to_desc(interp, &v)?; + interp.temp_roots.extend(desc_values(&desc)); + descs.push((k, desc)); + } + for (k, desc) in descs { + interp.use_fuel()?; + interp.define_own_property(o, k, &desc, true)?; + } + Ok(()) + })(); + interp.temp_roots.truncate(mark); + r +} + +/// Makes every own property non-configurable, and with `freeze` every data property read-only, +/// then makes the object non-extensible (sections 15.2.3.8 and 15.2.3.9). +fn lock(interp: &mut Interp, v: Value, freeze: bool) -> JsResult { + let Value::Object(o) = v else { return Ok(v) }; + for k in interp.own_keys(o)? { + interp.use_fuel()?; + let Some(prop) = interp.get_own_property(o, &k)? else { continue }; + let mut desc = match &prop { + Prop::Data { value, flags } => Desc::data(value.clone(), *flags), + Prop::Accessor { get, set, flags } => Desc { + get: Some(*get), + set: Some(*set), + enumerable: Some(flags & ENUMERABLE != 0), + configurable: Some(flags & CONFIGURABLE != 0), + ..Desc::default() + }, + }; + desc.configurable = Some(false); + if freeze && desc.is_data() { + desc.writable = Some(false); + } + interp.define_own_property(o, k, &desc, true)?; + } + if let Some(obj) = interp.heap.object_mut(o) { + obj.extensible = false; + } + Ok(Value::Object(o)) +} + +fn seal(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + lock(interp, arg(args, 0), false) +} + +fn freeze(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + lock(interp, arg(args, 0), true) +} + +/// `Object.preventExtensions` (section 15.2.3.10). +fn prevent_extensions(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let v = arg(args, 0); + if let Value::Object(o) = v + && let Some(obj) = interp.heap.object_mut(o) + { + obj.extensible = false; + } + Ok(v) +} + +/// Whether no own property is configurable (and with `frozen`, no data property is writable) +/// and the object is not extensible (sections 15.2.3.11 and 15.2.3.12). +fn locked(interp: &mut Interp, v: &Value, frozen: bool) -> JsResult { + let Value::Object(o) = v else { return Ok(Value::Bool(true)) }; + let o = *o; + for k in interp.own_keys(o)? { + interp.use_fuel()?; + let Some(prop) = interp.get_own_property(o, &k)? else { continue }; + if prop.configurable() || (frozen && matches!(prop, Prop::Data { flags, .. } if flags & WRITABLE != 0)) { + return Ok(Value::Bool(false)); + } + } + Ok(Value::Bool(!interp.heap.object(o).is_some_and(|o| o.extensible))) +} + +fn is_sealed(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + locked(interp, &arg(args, 0), false) +} + +fn is_frozen(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + locked(interp, &arg(args, 0), true) +} + +/// `Object.isExtensible` (section 15.2.3.13). +fn is_extensible(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let Value::Object(o) = arg(args, 0) else { return Ok(Value::Bool(false)) }; + Ok(Value::Bool(interp.heap.object(o).is_some_and(|o| o.extensible))) +} + +/// `Object.keys` (section 15.2.3.14). +fn keys(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult { + let o = interp.to_object(&arg(args, 0))?; + interp.rooted(&[Value::Object(o)], |interp| { + let mut names = Vec::new(); + for k in interp.own_keys(o)? { + interp.use_fuel()?; + if interp.get_own_property(o, &k)?.is_some_and(|p| p.enumerable()) { + names.push(k); + } + } + names_array(interp, names) + }) +} diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs new file mode 100644 index 000000000..5d108f8f6 --- /dev/null +++ b/crates/js/src/builtins_tests.rs @@ -0,0 +1,503 @@ +use proptest::prelude::*; + +use crate::{JsValue, LimitKind, Limits, Options, Realm, RunError, RunErrorKind}; + +fn run(src: &str) -> JsValue { + match Realm::new().run(src) { + Ok(v) => v, + Err(e) => panic!("{src}\n{e:?}"), + } +} + +fn num(src: &str) -> f64 { + match run(src) { + JsValue::Number(n) => n, + other => panic!("{src}: {other:?}"), + } +} + +fn string(src: &str) -> String { + match run(src) { + JsValue::String(s) => s, + other => panic!("{src}: {other:?}"), + } +} + +fn truth(src: &str) { + assert_eq!(run(src), JsValue::Bool(true), "{src}"); +} + +/// The run throws an error whose message starts with `name`. +fn throws(src: &str, name: &str) { + match Realm::new().run(src) { + Err(RunError { kind: RunErrorKind::Thrown, message, .. }) => assert!(message.starts_with(name), "{src}: {message}"), + other => panic!("{src}: {other:?}"), + } +} + +fn small(limits: Limits) -> Realm { + Realm::with_options(Options::default(), limits) +} + +fn limit(realm: &mut Realm, src: &str) -> LimitKind { + match realm.run(src) { + Err(RunError { kind: RunErrorKind::Limit(k), .. }) => k, + other => panic!("{src}: {other:?}"), + } +} + +// ---- Object ---- + +#[test] +fn define_property_and_descriptors_follow_section_8_12_9() { + truth( + "var o = {}; Object.defineProperty(o, 'x', {value: 1}); var d = Object.getOwnPropertyDescriptor(o, 'x'); \ + d.value === 1 && d.writable === false && d.enumerable === false && d.configurable === false", + ); + truth("var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2; o.x === 1 && Object.keys(o).length === 0"); + throws("'use strict'; var o = {}; Object.defineProperty(o, 'x', {value: 1}); o.x = 2;", "TypeError"); + throws("var o = {}; Object.defineProperty(o, 'x', {value: 1}); Object.defineProperty(o, 'x', {value: 2});", "TypeError"); + truth("var o = {}; Object.defineProperty(o, 'x', {value: NaN}); Object.defineProperty(o, 'x', {value: NaN}); o.x !== o.x"); + throws("var o = {}; Object.defineProperty(o, 'x', {value: 0}); Object.defineProperty(o, 'x', {value: -0});", "TypeError"); + truth( + "var o = {}, log = []; Object.defineProperty(o, 'x', {get: function () { return 7; }, set: function (v) { log.push(v); }, enumerable: true}); \ + o.x = 3; o.x === 7 && log[0] === 3 && Object.keys(o)[0] === 'x' && typeof Object.getOwnPropertyDescriptor(o, 'x').get === 'function'", + ); + throws("Object.defineProperty({}, 'x', {get: function () {}, value: 1})", "TypeError"); + throws("Object.defineProperty({}, 'x', {get: 1})", "TypeError"); + throws("Object.defineProperty(1, 'x', {})", "TypeError"); + // A configurable data property becomes an accessor and keeps its enumerability. + truth( + "var o = {x: 1}; Object.defineProperty(o, 'x', {get: function () { return 2; }}); \ + var d = Object.getOwnPropertyDescriptor(o, 'x'); o.x === 2 && d.enumerable && d.configurable && d.set === undefined", + ); +} + +#[test] +fn array_length_and_index_definitions_follow_section_15_4_5_1() { + truth("var a = [1, 2, 3]; Object.defineProperty(a, 'length', {value: 1}); a.length === 1 && !(1 in a)"); + truth( + "var a = [1, 2, 3]; Object.defineProperty(a, '1', {value: 2, configurable: false}); \ + var threw = false; try { Object.defineProperty(a, 'length', {value: 0}); } catch (e) { threw = e instanceof TypeError; } \ + threw && a.length === 2 && a[1] === 2 && !(2 in a)", + ); + truth("var a = [1, 2, 3]; Object.defineProperty(a, '1', {configurable: false}); a.length = 0; a.length === 2"); + truth("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a[5] = 1; a.length === 1 && !(5 in a)"); + throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); Object.defineProperty(a, '3', {value: 1});", "TypeError"); + throws("Object.defineProperty([], 'length', {value: -1})", "RangeError"); + truth("var a = []; Object.defineProperty(a, '4', {value: 1, enumerable: true, writable: true, configurable: true}); a.length === 5"); + throws("var a = Object.freeze([1, 2]); a.push(3);", "TypeError"); +} + +#[test] +fn object_functions_list_create_and_lock_objects() { + truth( + "var o = Object.create({inherited: 1}, {own: {value: 2, enumerable: true}}); o.inherited === 1 && o.own === 2 && Object.keys(o).join() === 'own'", + ); + truth("Object.getPrototypeOf(Object.create(null)) === null && Object.getPrototypeOf([]) === Array.prototype"); + assert_eq!(string("Object.getOwnPropertyNames(Object('ab')).join()"), "0,1,length"); + assert_eq!(string("var a = [5]; a.k = 1; Object.getOwnPropertyNames(a).join()"), "length,0,k"); + // Object.keys lists names in the order for-in visits them (section 15.2.3.14). + assert_eq!(string("var o = {b: 1, a: 2, 3: 3}, s = ''; for (var k in o) s += k; Object.keys(o).join('') === s ? s : 'differs'"), "ba3"); + truth("var o = Object.seal({x: 1}); o.x = 2; delete o.x; o.y = 1; o.x === 2 && o.y === undefined && Object.isSealed(o) && !Object.isFrozen(o)"); + truth("var o = Object.freeze({x: 1}); o.x = 2; o.x === 1 && Object.isFrozen(o) && !Object.isExtensible(o)"); + truth("var o = Object.preventExtensions({x: 1}); o.y = 1; o.y === undefined && Object.isSealed(o) === false && Object.isExtensible(o) === false"); + truth("Object.isFrozen(Object.preventExtensions({})) && Object.isFrozen(1) && Object.freeze(1) === 1 && Object.keys('ab').join() === '0,1'"); + truth("var o = Object.defineProperties({}, {a: {value: 1, enumerable: true}, b: {get: function () { return 2; }}}); o.a + o.b === 3"); + // A frozen arguments object no longer follows its parameters. + truth("function f(a) { Object.freeze(arguments); a = 2; return arguments[0]; } f(1) === 1"); + truth("function f(a) { Object.defineProperty(arguments, '0', {value: 5}); return a; } f(1) === 5"); +} + +#[test] +fn prototype_methods_are_writable_so_scripts_can_replace_them() { + truth("var d = Object.getOwnPropertyDescriptor(Array.prototype, 'map'); d.writable && d.configurable && !d.enumerable"); + truth("Array.prototype.first = function () { return this[0]; }; [4].first() === 4"); + truth( + "var old = Array.prototype.join; Array.prototype.join = function () { return 'x'; }; var r = [1].toString(); Array.prototype.join = old; r === 'x'", + ); + truth("var d = Object.getOwnPropertyDescriptor(Array, 'prototype'); !d.writable && !d.enumerable && !d.configurable"); + truth("var d = Object.getOwnPropertyDescriptor(Math, 'PI'); !d.writable && !d.enumerable && !d.configurable"); +} + +// ---- Function ---- + +#[test] +fn bind_fixes_this_and_leading_arguments() { + assert_eq!(num("function f(a, b, c) { return this.k + a + b + c; } var g = f.bind({k: 1}, 2); g(3, 4)"), 10.0); + assert_eq!(num("function f(a, b, c) {} f.bind(null, 1).length"), 2.0); + assert_eq!(num("function f(a) {} f.bind(null, 1, 2, 3).length"), 0.0); + truth("function P(x, y) { this.s = x + y; } var B = P.bind({ignored: 1}, 2); var p = new B(3); p.s === 5 && p instanceof P && p instanceof B"); + truth("var g = function () { return this; }.bind(7); typeof g() === 'object' && g().constructor === undefined || true"); + truth("var g = Math.max.bind(null, 5); g(1) === 5 && g(9) === 9"); + throws("Function.prototype.bind.call({})", "TypeError"); + throws("'use strict'; function f() {} f.bind(null).caller", "TypeError"); + truth("function f() { return arguments.length; } f.bind(null, 1).bind(null, 2)(3) === 3"); + truth("Object.prototype.toString.call(function () {}.bind()) === '[object Function]'"); +} + +// ---- Array ---- + +#[test] +fn the_array_constructor_and_is_array() { + truth("var a = new Array(3); a.length === 3 && !(0 in a) && Array(2).length === 2"); + truth("var a = new Array(1, 2); a.length === 2 && a[1] === 2 && new Array('3').length === 1"); + throws("new Array(-1)", "RangeError"); + throws("new Array(1.5)", "RangeError"); + truth("new Array(4294967295).length === 4294967295"); + truth("Array.isArray([]) && !Array.isArray({length: 0}) && !Array.isArray(Array.prototype.slice) && Array.isArray(Array.prototype)"); +} + +#[test] +fn methods_that_change_the_array() { + assert_eq!(string("var a = [1, 2]; var n = a.push(3, 4); n + ':' + a.join()"), "4:1,2,3,4"); + assert_eq!(string("var a = [1, 2, 3]; a.pop() + ':' + a.join() + ':' + [].pop()"), "3:1,2:undefined"); + assert_eq!(string("var a = [1, 2, 3]; a.shift() + ':' + a.join()"), "1:2,3"); + assert_eq!(string("var a = [3]; a.unshift(1, 2) + ':' + a.join()"), "3:1,2,3"); + assert_eq!(string("[1, , 3].reverse().join()"), "3,,1"); + truth("var a = [1, , 3, , ]; a.reverse(); !(0 in a) && a[1] === 3 && !(2 in a) && a[3] === 1 && a.length === 4"); + assert_eq!(string("var a = [1, 2, 3, 4, 5]; var r = a.splice(1, 2, 'x'); r.join() + ':' + a.join()"), "2,3:1,x,4,5"); + assert_eq!(string("var a = [1, 2, 3]; var r = a.splice(1, 0, 'x', 'y'); r.length + ':' + a.join()"), "0:1,x,y,2,3"); + assert_eq!(string("var a = [1, 2, 3]; a.splice(-2).join() + ':' + a.join()"), "2,3:1"); + assert_eq!(string("[1, 2, 3, 4].slice(1, -1).join() + ':' + [1, 2].slice(5).length"), "2,3:0"); + assert_eq!(string("[1].concat([2, [3]], 4).length + ':' + [1].concat([2, , 3]).join()"), "4:1,2,,3"); + // The methods are generic over array-like objects. + assert_eq!( + string("var o = {length: 2, 0: 'a', 1: 'b'}; Array.prototype.push.call(o, 'c'); o.length + Array.prototype.join.call(o, '-')"), + "3a-b-c" + ); + assert_eq!(num("var o = {length: '2', 0: 1, 1: 2}; Array.prototype.reverse.call(o); o[0] * 10 + o[1]"), 21.0); + throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a.push(2)", "TypeError"); +} + +#[test] +fn searching_and_iteration_methods() { + assert_eq!(num("[1, 2, 3, 2].indexOf(2) * 10 + [1, 2, 3, 2].lastIndexOf(2)"), 13.0); + assert_eq!(num("[1, 2, 3].indexOf(1, -2) + [NaN].indexOf(NaN) + [1, 2].lastIndexOf(1, -3)"), -3.0); + assert_eq!(num("[1, 2, 3].indexOf(3, -1) * 10 + [1, , 3].indexOf(undefined) * -1"), 21.0); + truth("[2, 4].every(function (x) { return x % 2 === 0; }) && ![2, 3].every(function (x) { return x % 2 === 0; })"); + truth("[1, 2].some(function (x) { return x > 1; }) && ![].some(function () { return true; })"); + assert_eq!(string("var s = ''; [1, , 3].forEach(function (x, i, a) { s += x + '@' + i + (a.length); }); s"), "1@033@23"); + assert_eq!(string("[1, 2, 3].map(function (x) { return x * this.k; }, {k: 2}).join()"), "2,4,6"); + truth("var m = [1, , 3].map(function (x) { return x; }); m.length === 3 && !(1 in m)"); + assert_eq!(string("[1, 2, 3, 4].filter(function (x) { return x % 2; }).join()"), "1,3"); + assert_eq!(num("[1, 2, 3].reduce(function (a, b) { return a + b; })"), 6.0); + assert_eq!(string("['a', 'b', 'c'].reduceRight(function (a, b) { return a + b; }, '')"), "cba"); + assert_eq!(num("[, 5, ].reduce(function (a, b) { return a + b; })"), 5.0); + throws("[].reduce(function () {})", "TypeError"); + throws("[1].forEach(1)", "TypeError"); + // Elements appended during iteration are not visited; elements deleted before they are + // reached are skipped. + assert_eq!(num("var a = [1, 2, 3], n = 0; a.forEach(function (x, i) { n++; a.push(9); if (i === 0) delete a[1]; }); n"), 2.0); +} + +#[test] +fn sort_orders_values_with_undefined_and_holes_last() { + assert_eq!(string("[3, 1, 10, 2].sort().join()"), "1,10,2,3"); + assert_eq!(string("[3, 1, 10, 2].sort(function (a, b) { return a - b; }).join()"), "1,2,3,10"); + truth("var a = [3, undefined, , 1]; a.sort(); a[0] === 1 && a[1] === 3 && a[2] === undefined && 2 in a && !(3 in a) && a.length === 4"); + // The sort is stable. + assert_eq!( + string( + "[{k: 1, v: 'a'}, {k: 0, v: 'b'}, {k: 1, v: 'c'}, {k: 0, v: 'd'}].sort(function (x, y) { return x.k - y.k; }).map(function (x) { return x.v; }).join('')" + ), + "bdac" + ); + // A comparison that throws stops the sort with its exception. + truth("var a = [2, 1, 3]; var e; try { a.sort(function () { throw 'stop'; }); } catch (x) { e = x; } e === 'stop'"); + // A comparison that changes the array or answers inconsistently leaves an array of the same + // length and no exception. + truth("var a = [5, 4, 3, 2, 1]; a.sort(function (x, y) { a.length = 1; a[7] = 0; return Math.random() - 0.5; }); a.length >= 5"); + truth("var a = []; for (var i = 0; i < 100; i++) a[i] = (i * 37) % 100; a.sort(function () { return 1; }); a.length === 100"); + throws("[1, 2].sort(1)", "TypeError"); + assert_eq!(string("Array.prototype.sort.call({length: 3, 0: 'c', 2: 'a'}) && 'ok'"), "ok"); +} + +#[test] +fn join_and_to_string_are_cycle_safe() { + assert_eq!(string("var a = [1, 2]; a.push(a); a.join()"), "1,2,"); + assert_eq!(string("var a = [1]; var b = [a, 2]; a.push(b); String2(a); function String2(x) { return '' + x; }"), "1,,2"); + assert_eq!(string("var a = [1, [2, [3]]]; a.toString()"), "1,2,3"); + assert_eq!(string("[null, undefined, 0].join('-')"), "--0"); + assert_eq!(string("Array.prototype.toString.call({join: 1})"), "[object Object]"); + assert_eq!(string("var a = [{toLocaleString: function () { return 'L'; }}, null]; a.push(a); a.toLocaleString()"), "L,,"); +} + +#[test] +fn sparse_arrays_of_any_length_cost_only_their_elements() { + // Each would visit four billion indices one by one; with 200,000 units of fuel each must + // finish by visiting only the indices that exist. + let mut realm = small(Limits { fuel: 200_000, ..Limits::default() }); + let cases = [ + ("var a = new Array(4294967295); a.join('')", JsValue::String(String::new())), + ("var a = []; a[4294967294] = 'z'; a[3] = 'y'; a.join('').length", JsValue::Number(2.0)), + ("var a = []; a[4294967294] = 2; a[7] = 1; a.sort(); a[0] * 10 + a[1] + (a.length === 4294967295 ? 0 : 100)", JsValue::Number(12.0)), + ("var a = []; a[4294967294] = 1; a.indexOf(1)", JsValue::Number(4_294_967_294.0)), + ("var a = []; a[4294967294] = 1; a.lastIndexOf(1, -1) + a.lastIndexOf(2)", JsValue::Number(4_294_967_293.0)), + ("var n = 0; var a = []; a[4294967294] = 1; a[0] = 1; a.forEach(function () { n++; }); n", JsValue::Number(2.0)), + ("var a = []; a[4294967294] = 1; a.map(function (x) { return x + 1; })[4294967294]", JsValue::Number(2.0)), + ("var a = []; a[4000000000] = 1; a[1] = 2; a.reverse(); a[0] * 10 + a[3999999999]", JsValue::Number(12.0)), + ("var a = []; a[4000000000] = 1; a.shift(); a[3999999999] + (a.length === 4000000000 ? 0 : 100)", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a.unshift(0); a[4000000001] + a[0]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a[1] = 2; var r = a.splice(1, 1); r[0] + a[3999999999]", JsValue::Number(3.0)), + ("var a = []; a[4000000000] = 1; a.splice(1, 0, 'x', 'y'); a[4000000002]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; a.slice(1)[3999999999]", JsValue::Number(1.0)), + ("var a = []; a[4000000000] = 1; [0].concat(a).length", JsValue::Number(4_000_000_002.0)), + ("var a = []; a[4000000000] = 2; a.reduce(function (x, y) { return x + y; }, 1)", JsValue::Number(3.0)), + ]; + for (src, want) in cases { + assert_eq!(realm.run(src), Ok(want), "{src}"); + } + // An index added while a method runs is visited. + let src = "var a = []; a[4000000000] = 1; var seen = []; a.forEach(function (x, i) { seen.push(i); if (i === 4000000000) {} }); \ + var b = []; b[1] = 1; b[3000000000] = 1; var n = 0; b.forEach(function (x, i) { n++; if (n === 1) { b[2000000000] = 2; b[3500000000] = 2; } }); n + seen.length"; + assert_eq!(realm.run(src), Ok(JsValue::Number(4.0))); +} + +#[test] +fn built_ins_end_with_a_limit_instead_of_running_on() { + let limits = Limits { heap_bytes: 8 << 20, fuel: 5_000_000, ..Limits::default() }; + assert_eq!(limit(&mut small(limits.clone()), "new Array(4294967295).join('x')"), LimitKind::Heap); + assert_eq!(limit(&mut small(limits.clone()), "JSON.stringify(new Array(4294967295))"), LimitKind::Heap); + assert_eq!(limit(&mut small(limits.clone()), "var a = [1]; for (;;) a = a.concat(a);"), LimitKind::Heap); + assert_eq!( + limit(&mut small(limits.clone()), "var o = {}; for (var i = 0; i < 20; i++) o = {a: o, b: o, c: o}; JSON.stringify(o)"), + LimitKind::Heap + ); + assert_eq!( + limit(&mut small(limits.clone()), "var o = []; for (var i = 0; i < 20000; i++) o = [o]; for (;;) JSON.stringify(o);"), + LimitKind::Fuel + ); + let mut realm = small(Limits { fuel: 100_000, ..Limits::default() }); + assert_eq!( + limit(&mut realm, "var a = []; for (var i = 0; i < 20000; i++) a[i * 1000] = i; a.length = 4294967295; a.sort(); a.sort();"), + LimitKind::Fuel + ); + assert_eq!(limit(&mut realm, "var a = [1, 2, 3, 4, 5, 6, 7, 8]; for (var i = 0; i < 14; i++) a = a.concat(a); a.join('')"), LimitKind::Fuel); + let mut realm = small(Limits { fuel: 1_000, ..Limits::default() }); + assert_eq!(limit(&mut realm, "var s = 'x'; for (var i = 0; i < 20; i++) s += s; JSON.parse('\"' + s + '\"')"), LimitKind::Fuel); +} + +#[test] +fn values_built_ins_hold_survive_collections() { + // A small heap makes the callbacks' garbage start collections while the results are built. + let mut realm = small(Limits { heap_bytes: 2 << 20, ..Limits::default() }); + let src = "function junk() { var t = []; for (var j = 0; j < 200; j++) t[j] = {j: j}; return t.length; } \ + var src = []; for (var i = 0; i < 300; i++) src[i] = i; \ + var m = src.map(function (x) { junk(); return {v: x}; }); \ + var f = src.filter(function (x) { junk(); return x % 2; }); \ + var r = src.reduce(function (acc, x) { junk(); return {sum: acc.sum + x}; }, {sum: 0}); \ + var s = src.slice().sort(function (a, b) { junk(); return b - a; }); \ + var j = JSON.parse(JSON.stringify(m), function (k, v) { junk(); return v; }); \ + m[299].v + f.length + r.sum + s[0] + j[299].v + j.length"; + assert_eq!(realm.run(src), Ok(JsValue::Number(299.0 + 150.0 + 44_850.0 + 299.0 + 299.0 + 300.0))); + assert!(realm.interp().heap.collections > 0); + // The callback is reachable only through the list `apply` read once the list is emptied. + let src = "var list = [function (x) { list.length = 0; junk(); return {v: x}; }]; \ + var r = Array.prototype.map.apply(src, list); \ + var g = Array.prototype.map.bind(src, function (x) { junk(); return {v: x}; }); g = g(); \ + r[299].v + g[299].v"; + assert_eq!(realm.run(src), Ok(JsValue::Number(598.0))); +} + +// ---- errors ---- + +#[test] +fn error_objects_have_names_messages_and_to_string() { + assert_eq!(string("new TypeError('bad').toString()"), "TypeError: bad"); + assert_eq!(string("RangeError('r').name + Error().message + (new Error(undefined).hasOwnProperty('message') ? 1 : 0)"), "RangeError0"); + truth("var e = new SyntaxError('s'); e instanceof SyntaxError && e instanceof Error && Object.prototype.toString.call(e) === '[object Error]'"); + assert_eq!(string("var e = new Error('m'); e.name = ''; e.toString()"), "m"); + assert_eq!(string("Error.prototype.toString.call({name: 'N'})"), "N"); + truth("URIError.prototype.name === 'URIError' && Object.getPrototypeOf(EvalError.prototype) === Error.prototype && Error.length === 1"); +} + +// ---- Math ---- + +#[test] +fn math_functions_follow_the_special_cases() { + truth( + "Math.max() === -Infinity && Math.min() === Infinity && isNaN(Math.max(1, NaN, 3)) && 1 / Math.max(-0, 0) === Infinity && 1 / Math.min(0, -0) === -Infinity", + ); + truth("Math.round(2.5) === 3 && Math.round(-2.5) === -2 && 1 / Math.round(-0.4) === -Infinity && Math.round(0.49999999999999994) === 0"); + truth("isNaN(Math.pow(1, NaN)) && isNaN(Math.pow(-1, Infinity)) && Math.pow(NaN, 0) === 1 && Math.pow(2, -1) === 0.5"); + truth("Math.abs(-3) === 3 && Math.floor(-1.5) === -2 && Math.ceil(-1.5) === -1 && 1 / Math.ceil(-0.5) === -Infinity"); + truth("Math.sqrt(-1) !== Math.sqrt(-1) && Math.atan2(0, -0) === Math.PI && Math.exp(0) === 1 && Math.log(Math.E) === 1"); + truth("Math.abs(Math.sin(Math.PI)) < 1e-15 && Math.cos(0) === 1 && Math.tan(0) === 0 && Math.asin(1) === Math.PI / 2"); + truth("var ok = true; for (var i = 0; i < 1000; i++) { var r = Math.random(); if (!(r >= 0 && r < 1)) ok = false; } ok"); + truth("Math.SQRT2 * Math.SQRT1_2 > 0.9999 && Math.LN2 > 0.69 && Math.LOG10E > 0.43 && Object.prototype.toString.call(Math) === '[object Math]'"); + truth("var n = 0; Math.max({valueOf: function () { n++; return NaN; }}, {valueOf: function () { n++; return 1; }}); n === 2"); +} + +// ---- JSON ---- + +#[test] +fn json_parse_reads_the_grammar_of_section_15_12_1() { + assert_eq!(num("var v = JSON.parse(' {\"a\": [1, -2.5e1, true, null, \"x\\\\u0041\\\\n\"], \"b\": {}} '); v.a[1] + v.a.length"), -20.0); + assert_eq!(string("JSON.parse('\"\\\\ud800\"').charCodeAt === undefined ? 'no string methods' : ''"), "no string methods"); + truth("JSON.parse('[]').length === 0 && JSON.parse('0') === 0 && 1 / JSON.parse('-0') === -Infinity && JSON.parse('{\"a\":1,\"a\":2}').a === 2"); + for bad in ["'{a: 1}'", "'[1,]'", "'01'", "'1.'", "'.5'", "'\"\\t\"'", "\"'x'\"", "'[1] 2'", "''", "'+1'", "'\"\\\\x\"'", "'{\"a\" 1}'"] { + throws(&format!("JSON.parse({bad})"), "SyntaxError"); + } + // The reviver sees every value bottom-up and can delete or replace it. + assert_eq!( + string( + "var seen = []; var v = JSON.parse('{\"a\": [1, 2], \"b\": 3}', function (k, v) { seen.push(k); return k === 'b' ? undefined : v; }); seen.join() + ':' + ('b' in v)" + ), + "0,1,a,b,:false" + ); + assert_eq!(num("JSON.parse('[1, 2]', function (k, v) { return typeof v === 'number' ? v * 10 : v; })[1]"), 20.0); +} + +#[test] +fn json_stringify_writes_values_with_replacers_and_indentation() { + assert_eq!( + string("JSON.stringify({a: [1, 'x', null, undefined, function () {}], b: undefined, c: true})"), + "{\"a\":[1,\"x\",null,null,null],\"c\":true}" + ); + assert_eq!(string("JSON.stringify('\\u0001\"\\\\\\n')"), "\"\\u0001\\\"\\\\\\n\""); + assert_eq!(string("JSON.stringify([NaN, Infinity, -0, 1e21])"), "[null,null,0,1e+21]"); + assert_eq!(run("JSON.stringify(undefined)"), JsValue::Undefined); + assert_eq!(run("JSON.stringify(function () {})"), JsValue::Undefined); + assert_eq!(string("JSON.stringify({a: 1, b: [1, 2], c: {}}, null, 2)"), "{\n \"a\": 1,\n \"b\": [\n 1,\n 2\n ],\n \"c\": {}\n}"); + assert_eq!(string("JSON.stringify([1], null, 'abcdefghijkl')"), "[\nabcdefghij1\n]"); + assert_eq!(string("JSON.stringify({a: 1, b: 2, c: 3}, ['c', 'a', 'c', 1])"), "{\"c\":3,\"a\":1}"); + assert_eq!(string("JSON.stringify({a: 1, b: 'x'}, function (k, v) { return typeof v === 'number' ? v + 1 : v; })"), "{\"a\":2,\"b\":\"x\"}"); + assert_eq!(string("JSON.stringify({toJSON: function (k) { return 'k=' + k; }})"), "\"k=\""); + assert_eq!(string("JSON.stringify({d: {toJSON: function (k) { return k; }}})"), "{\"d\":\"d\"}"); + assert_eq!(string("JSON.stringify([Object(false), Object(true)])"), "[false,true]"); + assert_eq!(string("JSON.stringify({}, null, 4) + JSON.stringify([], null, 4)"), "{}[]"); + throws("var a = []; a.push(a); JSON.stringify(a)", "TypeError"); + throws("var o = {}; o.x = {y: o}; JSON.stringify(o)", "TypeError"); + // The same object twice, not nested in itself, is not a cycle. + assert_eq!(string("var x = {}; JSON.stringify([x, x])"), "[{},{}]"); +} + +#[test] +fn deep_json_nesting_needs_no_native_stack() { + let handle = std::thread::Builder::new() + .stack_size(256 << 10) + .spawn(|| { + let mut realm = Realm::new(); + realm.run( + "var depth = 100000; var s = ''; for (var i = 0; i < depth; i++) s += '['; for (i = 0; i < depth; i++) s += ']'; \ + var v = JSON.parse(s); var back = JSON.stringify(v); var n = 0; JSON.parse(s, function (k, v) { n++; return v; }); \ + back.length === 2 * depth && n === depth", + ) + }) + .unwrap(); + assert_eq!(handle.join().unwrap(), Ok(JsValue::Bool(true))); +} + +// ---- global functions ---- + +#[test] +fn global_number_functions() { + truth("parseInt(' 0x1F') === 31 && parseInt('-12px') === -12 && parseInt('z', 36) === 35 && isNaN(parseInt('', 10)) && isNaN(parseInt('1', 1))"); + truth( + "parseInt('011') === 11 && parseInt('11', 2) === 3 && parseInt('0x10', 16) === 16 && isNaN(parseInt('0x', 16)) && parseInt('12', 3.9) === 5", + ); + truth("parseInt('123456789012345678901234567890') === 1.2345678901234568e29 && 1 / parseInt('-0') === -Infinity"); + truth( + "parseFloat(' 3.5e2x') === 350 && parseFloat('-.5') === -0.5 && parseFloat('Infinityx') === Infinity && isNaN(parseFloat('e5')) && parseFloat('1e') === 1", + ); + truth("isNaN('x') && !isNaN('1') && isFinite('1') && !isFinite(Infinity) && !isFinite(NaN)"); + truth("NaN !== NaN && Infinity === 1 / 0 && undefined === void 0 && !Object.getOwnPropertyDescriptor(this, 'NaN').writable"); +} + +#[test] +fn uri_functions_encode_and_decode_utf8() { + assert_eq!(string("encodeURIComponent('a b&c/\\u00e9\\ud83d\\ude00')"), "a%20b%26c%2F%C3%A9%F0%9F%98%80"); + assert_eq!(string("encodeURI('http://x.y/a b?c=d#e')"), "http://x.y/a%20b?c=d#e"); + assert_eq!(string("decodeURIComponent('a%20b%26%C3%A9%F0%9F%98%80') === 'a b&\\u00e9\\ud83d\\ude00' ? 'ok' : 'no'"), "ok"); + assert_eq!(string("decodeURI('%3B%2F%20%23')"), "%3B%2F %23"); + for bad in [ + "encodeURI('\\ud800')", + "encodeURIComponent('\\udc00x')", + "decodeURI('%')", + "decodeURI('%C3')", + "decodeURI('%C0%80')", + "decodeURI('%ED%A0%80')", + "decodeURI('%FF')", + ] { + throws(bad, "URIError"); + } +} + +#[test] +fn escape_and_unescape_follow_annex_b() { + assert_eq!(string("escape('a b+/@\\u00e9\\u0100')"), "a%20b+/@%E9%u0100"); + assert_eq!(string("unescape('a%20b%E9%u0100%zz%u12') === 'a b\\u00e9\\u0100%zz%u12' ? 'ok' : 'no'"), "ok"); +} + +// ---- robustness ---- + +/// Calls of built-ins with arguments of every kind. +const CALLS: &[&str] = &[ + "[1,,3].join(X)", + "[3,1,2].sort(X)", + "[1,2,3].splice(X, X, X)", + "[1,2].slice(X, X)", + "[].concat(X, [X])", + "[1,2,3].map(X)", + "[1,2].reduce(X, X)", + "[1,2].indexOf(X, X)", + "[1,2].lastIndexOf(X, X)", + "Array.prototype.push.call(X, X)", + "Array.prototype.unshift.call(X, X)", + "Array.prototype.reverse.call(X)", + "Array(X)", + "Object.keys(X)", + "Object.freeze(X)", + "Object.defineProperty(X, X, X)", + "Object.create(X, X)", + "Object.getOwnPropertyNames(X)", + "JSON.stringify(X, X, X)", + "JSON.parse(X, X)", + "Math.max(X, X)", + "Math.pow(X, X)", + "parseInt(X, X)", + "parseFloat(X)", + "decodeURIComponent(X)", + "encodeURI(X)", + "unescape(X)", + "escape(X)", + "Function.prototype.bind.call(X, X)", +]; + +const ARGS: &[&str] = &[ + "undefined", + "null", + "0", + "-1", + "4294967295", + "NaN", + "'%E0'", + "'[1,{\"a\":2}]'", + "''", + "[]", + "{length: 4294967295}", + "{}", + "function (a, b) { return b - a; }", + "{valueOf: function () { throw 1; }}", + "{get length() { return 3; }}", + "Object.freeze([1, 2])", + "{value: 1, get: function () {}}", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn arbitrary_built_in_calls_never_panic(call in 0..CALLS.len(), args in proptest::collection::vec(0..ARGS.len(), 3)) { + let mut src = CALLS[call].to_string(); + for a in &args { + src = src.replacen('X', ARGS[*a], 1); + } + let src = src.replace('X', "1"); + let mut realm = small(Limits { fuel: 200_000, heap_bytes: 4 << 20, frames: 200, reentry: 8 }); + if let Err(e) = realm.run(&src) { + prop_assert!(e.kind != RunErrorKind::Internal, "{src}: {e:?}"); + } + } +} diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 82268f489..66dc00b24 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -170,11 +170,14 @@ pub(crate) enum NativeKind { Eval, } +/// What a function object runs. `Bound` is a function made by `Function.prototype.bind` (section +/// 15.3.4.5): calling it calls `target` with `this` and `args` before the arguments it is given. #[derive(Clone)] pub(crate) enum Func { Closure { code: Rc, env: EnvId }, Native { f: NativeFn, kind: NativeKind, constructor: bool, name: &'static str }, Host { f: Rc, name: Rc }, + Bound { target: ObjId, this: Value, args: Rc<[Value]> }, } /// A sloppy mode `arguments` object's link to its function's parameters (section 10.6): index @@ -204,25 +207,31 @@ pub(crate) enum Class { Function(Func), Error, Arguments(Box), - /// A `Boolean` object made by ToObject. - Boolean, + /// A `Boolean` object made by ToObject, with its primitive value. + Boolean(bool), /// A `Number` object made by ToObject. Number, String(Str), RegExp, ForIn(Box), + /// The `Math` object (section 15.8). + Math, + /// The `JSON` object (section 15.12). + Json, } impl Class { /// The `[[Class]]` name `Object.prototype.toString` reports. pub(crate) fn name(&self) -> &'static str { match self { + Class::Math => "Math", + Class::Json => "JSON", Class::Ordinary | Class::ForIn(_) => "Object", Class::Array { .. } => "Array", Class::Function(_) => "Function", Class::Error => "Error", Class::Arguments(_) => "Arguments", - Class::Boolean => "Boolean", + Class::Boolean(_) => "Boolean", Class::Number => "Number", Class::String(_) => "String", Class::RegExp => "RegExp", @@ -289,6 +298,8 @@ pub(crate) const OBJECT_BYTES: usize = 96; pub(crate) const ENV_BYTES: usize = 64; /// The estimated bytes of a rope node. pub(crate) const ROPE_BYTES: usize = 48; +/// The estimated bytes of each argument a bound function holds. +pub(crate) const BOUND_ARG_BYTES: usize = 24; /// The estimated bytes of a piece of code without its instructions, tables and strings. const CODE_BYTES: usize = 384; @@ -305,6 +316,10 @@ pub(crate) struct Heap { pub(crate) bytes: usize, /// How many collections have run. pub(crate) collections: usize, + /// Counts the properties whose names start with a digit (the array indices among them) added + /// to any object, so an operation that lists the indices an object has can tell whether a + /// script added one since. + pub(crate) index_epoch: u64, } impl Heap { @@ -521,11 +536,20 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u Prop::Accessor { get, set, .. } => work.extend(get.iter().chain(set.iter()).map(|f| f.0)), } } + let mut extra = 0; match &o.class { Class::Function(Func::Closure { env, code }) => { work.push(env.0); mark_code(code, strings); } + Class::Function(Func::Bound { target, this, args }) => { + work.push(target.0); + mark_value(this, work, strings); + for a in args.iter() { + mark_value(a, work, strings); + } + extra = BOUND_ARG_BYTES * args.len(); + } Class::Arguments(map) => work.push(map.env.0), Class::String(s) => strings.add(s), Class::ForIn(state) => { @@ -534,7 +558,7 @@ fn trace_object(o: &Object, work: &mut Vec, strings: &mut StringSizes) -> u } _ => {} } - OBJECT_BYTES + PROP_BYTES * o.props.len() + OBJECT_BYTES + PROP_BYTES * o.props.len() + extra } fn trace_env(e: &Env, work: &mut Vec, strings: &mut StringSizes) -> usize { diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 7df501699..57adbbb9b 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -161,6 +161,11 @@ pub(crate) struct Interp { pub(crate) throw_site: Option, /// The last exception a run did not catch. pub(crate) last_thrown: Value, + /// The state of `Math.random`'s generator. + pub(crate) random: u64, + /// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a + /// cycle back to one of them joins as an empty string. + pub(crate) join_stack: Vec, } /// The least heap size a collection waits for. @@ -186,6 +191,15 @@ fn index_keys_bytes(len: usize) -> usize { total } +/// A seed for `Math.random` that differs from realm to realm, from the standard library's +/// randomly keyed hasher; never zero. +fn random_seed() -> u64 { + use std::hash::{BuildHasher, Hasher}; + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(0x9e37_79b9_7f4a_7c15); + h.finish() | 1 +} + /// The estimated bytes of a binding or property named by `len` code units. fn binding_bytes(len: usize) -> usize { PROP_BYTES + string_bytes(len) @@ -286,6 +300,8 @@ impl Interp { source_units: 0, throw_site: None, last_thrown: Value::Undefined, + random: random_seed(), + join_stack: Vec::new(), }; crate::builtins::install(&mut interp); interp.gc_threshold = interp.next_threshold(); @@ -981,13 +997,210 @@ impl Interp { *length = i + 1; } let name_len = k.len(); + let index = new && k.first().is_some_and(|&u| (u16::from(b'0')..=u16::from(b'9')).contains(&u)); o.props.insert(k, prop); if new { self.heap.bytes += PROP_BYTES + string_bytes(name_len); } + if index { + self.heap.index_epoch = self.heap.index_epoch.wrapping_add(1); + } Ok(()) } + /// [[DefineOwnProperty]] (section 8.12.9, with the array rules of section 15.4.5.1 and the + /// arguments object rules of section 10.6). Returns whether the property was defined; with + /// `throw` a rejection throws a `TypeError` instead. + pub(crate) fn define_own_property(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult { + match &self.object(id)?.class { + Class::Array { length, length_writable } => { + let (length, length_writable) = (*length, *length_writable); + if is(&k, "length") { + return self.define_array_length(id, desc, throw); + } + if let Some(i) = array_index(&k) + && i >= length + && !length_writable + { + return self.reject_bool(throw, &k, "the array's length is read-only"); + } + self.define_ordinary(id, k, desc, throw) + } + Class::Arguments(map) => { + let mapped = array_index(&k).and_then(|i| map.names.get(i as usize)).and_then(Option::as_ref).cloned(); + if !self.define_ordinary(id, k.clone(), desc, false)? { + return self.reject_bool(throw, &k, "the property cannot be redefined"); + } + if let Some(name) = mapped { + let env = match &self.object(id)?.class { + Class::Arguments(map) => Some(map.env), + _ => None, + }; + if let Some(v) = desc.value.clone().filter(|_| !desc.is_accessor()) + && let Some(env) = env + && let Some(Env::Decl { bindings, .. }) = self.heap.env_mut(env) + && let Some(b) = bindings.get_mut(&name) + { + b.value = v; + } + if (desc.is_accessor() || desc.writable == Some(false)) + && let Class::Arguments(map) = &mut self.object_mut(id)?.class + && let Some(slot) = array_index(&k).and_then(|i| map.names.get_mut(i as usize)) + { + *slot = None; + } + } + Ok(true) + } + _ => self.define_ordinary(id, k, desc, throw), + } + } + + fn reject_bool(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult { + if throw { + let message = format!("cannot define property '{}': {why}", String::from_utf16_lossy(k)); + return Err(self.type_error(&message)); + } + Ok(false) + } + + /// The default [[DefineOwnProperty]] (section 8.12.9). + fn define_ordinary(&mut self, id: ObjId, k: Key, desc: &Desc, throw: bool) -> JsResult { + let current = self.get_own_property(id, &k)?; + let o = self.object(id)?; + let virtual_string = matches!(&o.class, Class::String(s) if is(&k, "length") || array_index(&k).is_some_and(|i| (i as usize) < s.len())); + let Some(current) = current else { + if !o.extensible { + return self.reject_bool(throw, &k, "the object is not extensible"); + } + let flags = desc.flags_or(0); + let prop = if desc.is_accessor() { + Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: flags & !WRITABLE } + } else { + Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags } + }; + self.define(id, k, prop)?; + return Ok(true); + }; + let Some(merged) = merge_desc(¤t, desc) else { return self.reject_bool(throw, &k, "the property cannot be redefined") }; + // An index or the length of a String object cannot change, so a definition that passes + // the checks leaves it as it is. + if virtual_string { + return Ok(true); + } + self.define(id, k, merged)?; + Ok(true) + } + + /// Defining an array's `length` (section 15.4.5.1, step 3). + fn define_array_length(&mut self, id: ObjId, desc: &Desc, throw: bool) -> JsResult { + let length_key = key("length"); + let Some(v) = desc.value.clone() else { + let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") }; + let Some(merged) = merge_desc(¤t, desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") }; + if let Class::Array { length_writable, .. } = &mut self.object_mut(id)?.class { + *length_writable = merged.flags() & WRITABLE != 0; + } + return Ok(true); + }; + let new = to_uint32(self.to_number(&v)?); + if f64::from(new) != self.to_number(&v)? { + return Err(self.throw(ErrorType::Range, "invalid array length")); + } + let Some(current) = self.get_own_property(id, &length_key)? else { return internal("an array without a length") }; + let Class::Array { length: old, length_writable } = self.object(id)?.class else { return internal("not an array") }; + let new_desc = Desc { value: Some(Value::Number(f64::from(new))), ..desc.clone() }; + if new >= old { + let Some(merged) = merge_desc(¤t, &new_desc) else { return self.reject_bool(throw, &length_key, "the length cannot be redefined") }; + if let Class::Array { length, length_writable } = &mut self.object_mut(id)?.class { + *length = new; + *length_writable = merged.flags() & WRITABLE != 0; + } + return Ok(true); + } + if !length_writable { + return self.reject_bool(throw, &length_key, "it is read-only"); + } + let checked = Desc { writable: None, ..new_desc }; + if merge_desc(¤t, &checked).is_none() { + return self.reject_bool(throw, &length_key, "the length cannot be redefined"); + } + let read_only = desc.writable == Some(false); + if !self.shrink_array(id, new, read_only)? { + return self.reject_bool(throw, &length_key, "an element cannot be deleted"); + } + Ok(true) + } + + /// Sets an array's length to `new`, deleting the elements at and past it from the last one + /// down (section 15.4.5.1, step 3.l). An element that cannot be deleted stops it, leaving + /// the length one past that element, and gives `false`. Each property takes a unit of fuel + /// before anything is removed. With `read_only` the length is made read-only afterwards + /// either way. + fn shrink_array(&mut self, id: ObjId, new: u32, read_only: bool) -> JsResult { + let Class::Array { length: old, .. } = self.object(id)?.class else { return internal("not an array") }; + let mut ok = true; + let mut length = new; + if new < old { + for _ in 0..self.object(id)?.props.len() { + self.use_fuel()?; + } + let o = self.object_mut(id)?; + let mut doomed: Vec<(u32, Key)> = + o.props.iter().filter_map(|(k, _)| array_index(k).filter(|&i| i >= new).map(|i| (i, k.clone()))).collect(); + doomed.sort_unstable_by_key(|d| std::cmp::Reverse(d.0)); + for (i, k) in doomed { + if o.props.get(&k).is_some_and(|p| !p.configurable()) { + length = i + 1; + ok = false; + break; + } + o.props.remove(&k); + } + } + if let Class::Array { length: l, length_writable } = &mut self.object_mut(id)?.class { + *l = length; + if read_only { + *length_writable = false; + } + } + Ok(ok) + } + + /// The names of an object's own properties (section 15.2.3.4): a String object's indices and + /// an array's or String object's `length` first, then the rest in the order they were added, + /// which is the order `for-in` visits them. Each name takes a unit of fuel, and the heap for + /// the list is checked first. + pub(crate) fn own_keys(&mut self, id: ObjId) -> JsResult> { + let o = self.object(id)?; + let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; + let has_length = matches!(o.class, Class::Array { .. } | Class::String(_)); + let count = o.props.len().saturating_add(string_len).saturating_add(usize::from(has_length)); + self.reserve_heap(index_keys_bytes(string_len).saturating_add(count.saturating_mul(KEY_ENTRY_BYTES)))?; + let mut out = Vec::with_capacity(count); + for i in 0..string_len { + self.use_fuel()?; + out.push(index_key(u32::try_from(i).unwrap_or(u32::MAX))); + } + if has_length { + out.push(key("length")); + } + for _ in 0..self.object(id)?.props.len() { + self.use_fuel()?; + } + out.extend(self.object(id)?.props.keys()); + Ok(out) + } + + /// Runs `f` with `values` kept reachable for any collection it starts. + pub(crate) fn rooted(&mut self, values: &[Value], f: impl FnOnce(&mut Self) -> JsResult) -> JsResult { + let mark = self.temp_roots.len(); + self.temp_roots.extend(values.iter().cloned()); + let r = f(self); + self.temp_roots.truncate(mark); + r + } + fn reject(&mut self, throw: bool, k: &[u16], why: &str) -> JsResult<()> { if throw { let message = format!("cannot assign to property '{}': {why}", String::from_utf16_lossy(k)); @@ -1061,25 +1274,12 @@ impl Interp { if f64::from(new) != n { return Err(self.throw(ErrorType::Range, "invalid array length")); } - let o = self.object(id)?; - let Class::Array { length: old, length_writable } = o.class else { return internal("not an array") }; + let Class::Array { length_writable, .. } = self.object(id)?.class else { return internal("not an array") }; if !length_writable { return self.reject(throw, &key("length"), "it is read-only"); } - if new < old { - // Shrinking visits every property: each takes fuel before anything is removed. - for _ in 0..o.props.len() { - self.use_fuel()?; - } - } - let o = self.object_mut(id)?; - let Class::Array { length, .. } = &mut o.class else { return internal("not an array") }; - *length = new; - if new < old { - let doomed: Vec = o.props.keys().into_iter().filter(|k| array_index(k).is_some_and(|i| i >= new)).collect(); - for k in doomed { - o.props.remove(&k); - } + if !self.shrink_array(id, new, false)? { + return self.reject(throw, &key("length"), "an element cannot be deleted"); } Ok(()) } @@ -1486,6 +1686,9 @@ impl Interp { /// Resolves `call` and `apply` and runs native and host functions; a function written in /// JavaScript comes back to be entered by the caller. fn prepare_call(&mut self, mut f: Value, mut this: Value, mut args: Vec) -> JsResult { + // The arguments `apply` reads and those a bound function adds are not on the operand + // stack, so a native function's collection must be told about them. + let mut unrooted = false; loop { let Some(func) = self.func_of(&f) else { return Err(self.type_error("the value is not a function")) }; match func { @@ -1507,6 +1710,23 @@ impl Interp { f = this; this = args.first().cloned().unwrap_or(Value::Undefined); args = self.list_from_array_like(&list)?; + unrooted = true; + } + Func::Bound { target, this: bound_this, args: bound_args } => { + self.use_fuel()?; + let mut all = Vec::with_capacity(bound_args.len().saturating_add(args.len())); + all.extend(bound_args.iter().cloned()); + all.append(&mut args); + args = all; + this = bound_this; + f = Value::Object(target); + unrooted = true; + } + Func::Native { f: native, .. } if unrooted => { + let mut roots = args.clone(); + roots.push(this.clone()); + let v = self.rooted(&roots, |interp| native(interp, this, &args, false))?; + return Ok(Prepared::Done(v)); } Func::Native { f: native, .. } => return Ok(Prepared::Done(native(self, this, &args, false)?)), Func::Host { f: host, .. } => { @@ -1582,8 +1802,17 @@ impl Interp { let n = argc as usize; let start = self.stack.len().checked_sub(n + 1).filter(|&s| s >= self.base()).ok_or_else(|| Abrupt::Internal("new below the stack".into()))?; - let f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); - let args: Vec = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default(); + let mut f = self.stack.get(start).cloned().unwrap_or(Value::Undefined); + let mut args: Vec = self.stack.get(start + 1..).map(<[Value]>::to_vec).unwrap_or_default(); + // A bound function constructs its target with its arguments first (section 15.3.4.5.2); + // the bound function on the stack keeps them reachable. + while let Some(Func::Bound { target, args: bound_args, .. }) = self.func_of(&f) { + self.use_fuel()?; + let mut all = bound_args.to_vec(); + all.append(&mut args); + args = all; + f = Value::Object(target); + } match self.func_of(&f) { Some(Func::Closure { code, env }) => { let Some(callee) = f.as_object() else { return internal("callee is not an object") }; @@ -2001,7 +2230,7 @@ impl Interp { let (class, proto) = match v { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), - Value::Bool(_) => (Class::Boolean, self.intrinsics.boolean_proto), + Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), Value::Number(_) => (Class::Number, self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; @@ -2146,7 +2375,13 @@ impl Interp { if !self.is_callable(f) { return Err(self.type_error("the right side of 'instanceof' is not a function")); } - let Some(fid) = f.as_object() else { return Ok(false) }; + let Some(mut fid) = f.as_object() else { return Ok(false) }; + // A bound function answers for its target (section 15.3.4.5.3). + while let Some(Func::Bound { target, .. }) = self.func_of(&Value::Object(fid)) { + self.use_fuel()?; + fid = target; + } + let f = &Value::Object(fid); let Value::Object(mut o) = v.clone() else { return Ok(false) }; let Value::Object(proto) = self.get_from(fid, &key("prototype"), f)? else { return Err(self.type_error("the function's prototype is not an object")); @@ -2221,6 +2456,107 @@ pub(crate) enum CompileError { Internal(String), } +/// A property descriptor (section 8.10): each field is `None` when it is absent. `get` and `set` +/// are `Some(None)` when present as `undefined`. +#[derive(Clone, Default, Debug)] +pub(crate) struct Desc { + pub(crate) value: Option, + pub(crate) get: Option>, + pub(crate) set: Option>, + pub(crate) writable: Option, + pub(crate) enumerable: Option, + pub(crate) configurable: Option, +} + +impl Desc { + /// A data descriptor with every attribute given. + pub(crate) fn data(value: Value, flags: u8) -> Self { + Desc { + value: Some(value), + writable: Some(flags & WRITABLE != 0), + enumerable: Some(flags & ENUMERABLE != 0), + configurable: Some(flags & CONFIGURABLE != 0), + ..Desc::default() + } + } + + pub(crate) fn is_accessor(&self) -> bool { + self.get.is_some() || self.set.is_some() + } + + pub(crate) fn is_data(&self) -> bool { + self.value.is_some() || self.writable.is_some() + } + + /// The attribute flags the descriptor gives, each absent one taken from `default`. + fn flags_or(&self, default: u8) -> u8 { + let pick = |field: Option, bit: u8| match field { + Some(true) => bit, + Some(false) => 0, + None => default & bit, + }; + pick(self.writable, WRITABLE) | pick(self.enumerable, ENUMERABLE) | pick(self.configurable, CONFIGURABLE) + } +} + +/// SameValue (section 9.12). +pub(crate) fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => (x.is_nan() && y.is_nan()) || (x == y && x.is_sign_negative() == y.is_sign_negative()), + _ => strict_eq(a, b), + } +} + +/// Steps 5 to 12 of [[DefineOwnProperty]] (section 8.12.9) for an existing property: the +/// property after `desc` is applied, or `None` when the change is not allowed. +fn merge_desc(current: &Prop, desc: &Desc) -> Option { + let flags = current.flags(); + let configurable = flags & CONFIGURABLE != 0; + if !configurable { + if desc.configurable == Some(true) { + return None; + } + if desc.enumerable.is_some_and(|e| e != (flags & ENUMERABLE != 0)) { + return None; + } + } + let attrs = |base: u8, writable_bit: bool| { + let f = desc.flags_or(base); + if writable_bit { f } else { f & !WRITABLE } + }; + match current { + Prop::Data { .. } if desc.is_accessor() => { + if !configurable { + return None; + } + Some(Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: attrs(flags & !WRITABLE, false) }) + } + Prop::Accessor { .. } if desc.is_data() => { + if !configurable { + return None; + } + Some(Prop::Data { value: desc.value.clone().unwrap_or(Value::Undefined), flags: attrs(flags & !WRITABLE, true) }) + } + Prop::Data { value, .. } => { + if !configurable && flags & WRITABLE == 0 { + if desc.writable == Some(true) { + return None; + } + if desc.value.as_ref().is_some_and(|v| !same_value(v, value)) { + return None; + } + } + Some(Prop::Data { value: desc.value.clone().unwrap_or_else(|| value.clone()), flags: attrs(flags, true) }) + } + Prop::Accessor { get, set, .. } => { + if !configurable && (desc.get.is_some_and(|g| g != *get) || desc.set.is_some_and(|s| s != *set)) { + return None; + } + Some(Prop::Accessor { get: desc.get.unwrap_or(*get), set: desc.set.unwrap_or(*set), flags: attrs(flags, false) }) + } + } +} + /// The strict equality comparison (section 11.9.6). pub(crate) fn strict_eq(a: &Value, b: &Value) -> bool { match (a, b) { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 9c0c192aa..8a80eaf01 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -23,9 +23,13 @@ //! verifier, and runs it on an interpreter that keeps JavaScript calls on its own frame stack, so //! deep recursion in a script does not use the native stack. Objects and environments live in an //! arena with a mark-and-sweep collector; strings built by repeated `+` are flattened and dropped -//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and a minimal -//! set of built-ins: `Object` and `Object.prototype`'s basic methods, `Function` with -//! `Function.prototype.call` and `apply`, `eval`, and the error constructors. Every run is bounded +//! with loops. The realm provides the language (sections 10 to 14 of ECMA-262 5.1) and these +//! built-ins: the global functions and constants (15.1, with Annex B's `escape` and `unescape`), +//! `Object` (15.2), `Function` with `call`, `apply` and `bind` (15.3), `Array` (15.4), the error +//! types (15.11), `Math` (15.8) and `JSON` (15.12). The array methods visit only the indices an +//! array or array-like object has, so a sparse array of length 2^32-1 costs as much as its +//! elements, and `JSON.parse`, `JSON.stringify` and the reviver walk use no native recursion. The +//! prototype methods are writable and configurable, so scripts can replace them. Every run is bounded //! by [`Limits`]: instructions run (fuel), heap bytes, frames, nested host calls, and an //! [`Interrupt`] flag another thread can set. A run that reaches a limit ends with //! [`RunErrorKind::Limit`], which a script's `try` cannot catch. @@ -169,6 +173,8 @@ impl LineTable { } } +#[cfg(test)] +mod builtins_tests; #[cfg(test)] mod interp_tests; #[cfg(test)] diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index 29d3aacb2..f95d57d17 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -188,6 +188,7 @@ impl Realm { interp.stack.clear(); interp.frames.clear(); interp.temp_roots.clear(); + interp.join_stack.clear(); match result { Ok(v) => Ok(interp.to_js_value(&v)), Err(Abrupt::Throw(v)) => { diff --git a/crates/js/tests/json2.rs b/crates/js/tests/json2.rs new file mode 100644 index 000000000..8692e9b30 --- /dev/null +++ b/crates/js/tests/json2.rs @@ -0,0 +1,83 @@ +//! Runs `json2.js`, Douglas Crockford's public-domain JSON polyfill, when `cargo xtask test262` has +//! fetched it (it sets `VECTORCRAFT_JSON2`; without it the test looks in `corpus/json2/` and skips +//! when the file is absent). +//! +//! The built-in `JSON` is removed first, so the polyfill defines its own. Each sample value is +//! written by the polyfill and by the built-in `JSON.stringify` (ECMA-262 5.1 section 15.12.3), +//! which must give the same text, and the text is read back by the polyfill's `parse` and by the +//! built-in `JSON.parse` (section 15.12.2), which must give values that write the same text again. +//! +//! The polyfill uses `Date`, `String.prototype.replace` and regular expression `test`. While the +//! interpreter lacks any of them, the test prints that it skipped and passes. + +use std::path::{Path, PathBuf}; + +use vectorcraft_js::{JsValue, Realm}; + +fn json2_file() -> Option { + if let Ok(path) = std::env::var("VECTORCRAFT_JSON2") { + return Some(PathBuf::from(path)); + } + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/json2"); + let mut dirs: Vec = + std::fs::read_dir(corpus).ok()?.filter_map(|e| e.ok().map(|e| e.path().join("json2.js"))).filter(|p| p.is_file()).collect(); + dirs.sort(); + dirs.pop() +} + +/// Built-ins the polyfill needs. +const NEEDS: &str = + "typeof Date === 'function' && typeof ''.replace === 'function' && typeof RegExp === 'function' && typeof /a/.test === 'function'"; + +/// Saves the built-in `JSON` and removes it, so the polyfill defines its own. +const BEFORE: &str = "var NATIVE = JSON; delete this.JSON; this.JSON = undefined;"; + +/// Writes and reads each sample with both implementations; gives the first difference, or "ok". +const ROUND_TRIP: &str = r#" +var POLY = JSON; +var samples = [ + 0, -1.5, 1e21, "", "plain", "quote \" backslash \\ newline \n tab \t \u0001 
 é", + true, false, null, [], {}, [1, [2, [3, []]]], {a: 1, b: [true, null, "x"], c: {d: {}}}, + [undefined, function () {}], {skip: undefined, f: function () {}, keep: 2}, [NaN, Infinity, -Infinity] +]; +var problem = "ok"; +for (var i = 0; i < samples.length && problem === "ok"; i++) { + var poly = POLY.stringify(samples[i]); + var native = NATIVE.stringify(samples[i]); + if (poly !== native) { + problem = "sample " + i + ": json2 wrote " + poly + ", the built-in wrote " + native; + } else if (poly !== undefined) { + if (NATIVE.stringify(POLY.parse(native)) !== native) { + problem = "sample " + i + ": json2 read " + native + " differently"; + } else if (POLY.stringify(NATIVE.parse(poly)) !== poly) { + problem = "sample " + i + ": the built-in read " + poly + " differently"; + } + } +} +var indented = {a: [1, {b: 2}], c: "d"}; +if (problem === "ok" && POLY.stringify(indented, null, 2) !== NATIVE.stringify(indented, null, 2)) { + problem = "indented output differs"; +} +if (problem === "ok" && POLY.stringify(indented, ["c"]) !== NATIVE.stringify(indented, ["c"])) { + problem = "property list output differs"; +} +problem; +"#; + +#[test] +fn json2_polyfill_round_trips_with_the_built_in_json() { + let Some(path) = json2_file() else { + println!("json2: SKIPPED (no json2.js; run cargo xtask test262)"); + return; + }; + let source = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut realm = Realm::new(); + if realm.run(NEEDS) != Ok(JsValue::Bool(true)) { + println!("json2: SKIPPED (the interpreter lacks Date, String.prototype.replace or RegExp, which json2.js uses)"); + return; + } + match realm.run(&format!("{BEFORE}\n{source}\n{ROUND_TRIP}")) { + Ok(JsValue::String(s)) if s == "ok" => println!("json2: the polyfill and the built-in JSON agree on every sample"), + other => panic!("json2: {other:?}"), + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs index 8c87bb19d..fdf72aa9c 100644 --- a/crates/js/tests/test262.rs +++ b/crates/js/tests/test262.rs @@ -19,6 +19,10 @@ //! passes when it completes, or, for `negative: phase: runtime`, when it throws an object whose //! `constructor` is the global its `type:` names. Its counts are printed apart from the parse //! counts, and its failures go to `run-failures.txt`. +//! +//! A third test runs the tests of the ES5 built-ins the same way: those under the folders +//! [`ES5_BUILT_INS`] names that have an `es5id:` (or, under `annexB/`, any edition's id) and no +//! `features:` tag. Its failures go to `built-ins-failures.txt`. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -193,15 +197,37 @@ fn es5_parse_subset() { ); } -/// Globals and method names the interpreter does not provide yet: a test that names one is -/// skipped. -const MISSING_BUILTINS: &str = "Array String Number Boolean Math JSON Date RegExp parseInt parseFloat isNaN isFinite encodeURI \ - decodeURI encodeURIComponent decodeURIComponent escape unescape print $262 $DONE Symbol Proxy Reflect Promise Map Set WeakMap \ - WeakSet ArrayBuffer DataView Int8Array Uint8Array Float64Array defineProperty defineProperties getOwnPropertyDescriptor \ - getOwnPropertyNames getPrototypeOf create keys freeze isFrozen seal isSealed preventExtensions isExtensible push pop shift unshift \ - slice splice concat join reverse sort indexOf lastIndexOf every some forEach map filter reduce reduceRight charAt charCodeAt \ - fromCharCode substring substr split replace match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential \ - bind now isArray compareArray verifyProperty fnGlobalObject"; +/// Globals and method names the interpreter does not provide yet: a test that names one, in its +/// own source or in a harness file it includes, is skipped. +const MISSING_BUILTINS: &str = "String Number Boolean Date RegExp print $262 $DONE Symbol Proxy Reflect Promise Map Set WeakMap \ + WeakSet ArrayBuffer DataView Int8Array Uint8Array Float64Array charAt charCodeAt fromCharCode substring substr split replace \ + match search exec test toUpperCase toLowerCase trim toFixed toPrecision toExponential now"; + +/// The directories under `test/built-ins/` and `test/annexB/built-ins/` that hold tests of the +/// ES5 built-ins (ECMA-262 5.1 sections 15.1 to 15.12 and Annex B.2). +const ES5_BUILT_INS: &[&str] = &[ + "built-ins/Object", + "built-ins/Function", + "built-ins/Array", + "built-ins/Error", + "built-ins/NativeErrors", + "built-ins/Math", + "built-ins/JSON", + "built-ins/global", + "built-ins/Infinity", + "built-ins/NaN", + "built-ins/undefined", + "built-ins/parseInt", + "built-ins/parseFloat", + "built-ins/isNaN", + "built-ins/isFinite", + "built-ins/encodeURI", + "built-ins/encodeURIComponent", + "built-ins/decodeURI", + "built-ins/decodeURIComponent", + "annexB/built-ins/escape", + "annexB/built-ins/unescape", +]; /// The stand-in for `String` that the harness's failure messages call. const HARNESS_STRING: &str = "function String(value) { return '' + value; }"; @@ -245,12 +271,28 @@ fn es5_language_subset_runs() { }; // Each test runs on a thread with a stack large enough for the nested runs the re-entry limit // allows in an unoptimized build. - let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_language_subset(&dir)).unwrap(); + let roots = vec![(dir.join("test").join("language"), true)]; + let handle = std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_subset(&dir, &roots, "language", "run-failures.txt")).unwrap(); handle.join().unwrap(); } -fn run_language_subset(dir: &Path) { - let language = dir.join("test").join("language"); +#[test] +fn es5_built_ins_subset_runs() { + let Some(dir) = suite_dir() else { + println!("test262 built-ins: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + // The Annex B tests name the edition they were written for with `esid:`, so they run without + // an `es5id:`. + let roots = ES5_BUILT_INS.iter().map(|d| (dir.join("test").join(d), !d.starts_with("annexB"))).collect::>(); + let handle = + std::thread::Builder::new().stack_size(64 << 20).spawn(move || run_subset(&dir, &roots, "built-ins", "built-ins-failures.txt")).unwrap(); + handle.join().unwrap(); +} + +/// Runs the tests under each root (with `true`, only those that name an ES5 section), prints the +/// counts and writes the failures to `report` beside the suite. +fn run_subset(dir: &Path, roots: &[(PathBuf, bool)], label: &str, report: &str) { let harness_dir = dir.join("harness"); // A file that cannot be read gives an empty source. let read = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap_or_default()).into_owned(); @@ -262,11 +304,12 @@ fn run_language_subset(dir: &Path) { let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); let (mut tests, mut skipped, mut passed, mut failed, mut panicked) = (0usize, 0usize, 0usize, 0usize, 0usize); let mut failures = String::new(); - for path in test_files(&language) { + let files = roots.iter().flat_map(|(root, es5_only)| test_files(root).into_iter().map(move |p| (p, *es5_only))); + for (path, es5_only) in files { let source = read(&path); let meta = meta(&source); let negative_parse = matches!(meta.negative_phase.as_deref(), Some("parse" | "resolution")); - if !meta.es5 || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { + if (es5_only && !meta.es5) || !meta.features.is_empty() || negative_parse || meta.flags.iter().any(|f| f == "module") { continue; } let includes: String = meta.includes.iter().map(|i| read(&harness_dir.join(i)) + "\n").collect(); @@ -307,12 +350,12 @@ fn run_language_subset(dir: &Path) { } } } - let report = dir.join("run-failures.txt"); + let report = dir.join(report); if let Err(e) = std::fs::write(&report, &failures) { println!("could not write {}: {e}", report.display()); } println!( - "test262 ES5 language subset through the interpreter: {tests} tests that need only the built-ins present, {passed} runs passed, \ + "test262 ES5 {label} subset through the interpreter: {tests} tests that need only the built-ins present, {passed} runs passed, \ {failed} failed ({panicked} panicked). Skipped {skipped} tests that need other built-ins, use syntax after ES5 or are asynchronous. Failures: {}", report.display() ); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 27e8b3d79..3ddeb2ab0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -654,7 +654,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap. Built-ins are limited to `Object`, `Function` with `call` and `apply`, `eval` and the error types. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only these built-ins: 3,074 of 3,078 runs pass (the 4 others expect the completion values of later editions). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next entry lists. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 3,362 of 3,387 runs pass (4 of the others expect the completion values of later editions, and 21 call `String` or `Number` methods). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. + - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects and visit only the indices that exist, so `new Array(4294967295).join("")` ends at once; `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 4,478 of 4,556 runs pass (16 of the failing tests call `String`, `Number` or `Boolean` methods, and 23 expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit to compare with the built-in `JSON`; that test runs once `Date`, `String.prototype.replace` and `RegExp` exist. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs index 30f6f2472..a1baa5c77 100644 --- a/xtask/src/test262.rs +++ b/xtask/src/test262.rs @@ -2,16 +2,33 @@ //! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against //! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then //! runs `vectorcraft-js`'s `test262` tests, which parse the ES5 parse subset, run the ES5 -//! `test/language` tests that need only the interpreter's built-ins, and print each one's counts. +//! `test/language` tests and the tests of the ES5 built-ins under `test/built-ins` and +//! `test/annexB/built-ins` that need only the interpreter's built-ins, and print each one's counts. //! -//! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the -//! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. +//! It also fetches `json2.js`, Douglas Crockford's public-domain JSON polyfill +//! (https://github.com/douglascrockford/JSON-js), at a pinned commit, checks it against its SHA-256 +//! and size, saves it in `corpus/json2/` (gitignored, never committed), and runs `vectorcraft-js`'s +//! `json2` test, which runs the polyfill and compares what it writes and reads with the built-in +//! `JSON`. +//! +//! Moving the pin: change [`COMMIT`] (or [`JSON2_COMMIT`]), run the command once with the new +//! commit to see the file's SHA-256 in the error, check the file, and update [`SHA256`] (or +//! [`JSON2_SHA256`]). -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::{cargo, root, run, sha256}; +/// The douglascrockford/JSON-js commit `json2.js` is read at. +const JSON2_COMMIT: &str = "7e83f38a2312429fd4933169c1f6a27fd65e889c"; + +/// The SHA-256 of `json2.js` at [`JSON2_COMMIT`]. +const JSON2_SHA256: &str = "86df14b56572e68a7e10b18e71804cb78c6890a7d6e534324822c23d12e858a2"; + +/// The largest `json2.js` accepted (the pinned one is 18,876 bytes). +const JSON2_MAX_BYTES: u64 = 1 << 20; + /// The tc39/test262 commit the suite is read at. const COMMIT: &str = "d53ebc8b1931d9f1541e43d59650776e4b766248"; @@ -25,11 +42,44 @@ pub fn run_suite() -> Result<(), String> { let dir = fetch()?; let mut c = cargo(); c.env("VECTORCRAFT_TEST262_DIR", &dir).args(["test", "-p", "vectorcraft-js", "--release", "--test", "test262", "--", "--nocapture"]); - run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display())) + run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display()))?; + let json2 = fetch_json2()?; + let mut c = cargo(); + c.env("VECTORCRAFT_JSON2", &json2).args(["test", "-p", "vectorcraft-js", "--release", "--test", "json2", "--", "--nocapture"]); + run(c, &format!("cargo test -p vectorcraft-js --release --test json2 -- --nocapture (VECTORCRAFT_JSON2={})", json2.display())) +} + +/// Fetches `json2.js` unless a copy with the pinned SHA-256 is there already, and returns its path. +fn fetch_json2() -> Result { + let base = root().join("corpus").join("json2").join(JSON2_COMMIT); + let file = base.join("json2.js"); + let matches = |p: &Path| -> Result { + let Ok(meta) = std::fs::metadata(p) else { return Ok(false) }; + if meta.len() > JSON2_MAX_BYTES { + return Err(format!("{} is {} bytes, over the {JSON2_MAX_BYTES}-byte limit", p.display(), meta.len())); + } + Ok(std::fs::read(p).is_ok_and(|b| sha256::hex(&b) == JSON2_SHA256)) + }; + if matches(&file)? { + println!("json2.js {JSON2_COMMIT} is in {}", file.display()); + return Ok(file); + } + std::fs::create_dir_all(&base).map_err(|e| format!("create {}: {e}", base.display()))?; + let url = format!("https://raw.githubusercontent.com/douglascrockford/JSON-js/{JSON2_COMMIT}/json2.js"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "--max-filesize", &JSON2_MAX_BYTES.to_string(), "-o"]).arg(&file).arg(&url); + run(curl, &format!("curl {url}"))?; + if !matches(&file)? { + let got = std::fs::read(&file).map(|b| sha256::hex(&b)).unwrap_or_default(); + let _ = std::fs::remove_file(&file); + return Err(format!("json2.js: sha256 {got} does not match the pinned {JSON2_SHA256}")); + } + println!("json2.js {JSON2_COMMIT} saved in {}", file.display()); + Ok(file) } /// Fetches and unpacks the suite unless it is there already, and returns its folder. -fn fetch() -> Result { +fn fetch() -> Result { let base = root().join("corpus").join("test262"); let dir = base.join(format!("test262-{COMMIT}")); let marker = dir.join(".vectorcraft-sha256"); From 1470dd5ca9a5e6f0c014b80212df7c0137b45bbe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 17:09:47 -0800 Subject: [PATCH 28/82] JavaScript: built-ins keep a new value rooted while they reserve room to store it Array methods that store an element (map, filter, slice, splice, concat, shift, unshift and sort), the JSON.parse reviver walk and JSON.stringify reserved heap for a value that only native code held. When the heap was near its limit, that reservation ran a collection that freed the value before it was stored, and the script later failed with "object out of range". The value now stays on the temporary roots until it is stored, and stringify roots an object from toJSON or a replacer before it writes the separator and key. --- crates/js/src/builtins.rs | 18 +++++++---- crates/js/src/builtins/array.rs | 2 +- crates/js/src/builtins/json.rs | 19 ++++++++--- crates/js/src/builtins_tests.rs | 57 +++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index df698d42f..7016e690d 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -154,10 +154,13 @@ fn has_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult { /// The estimated bytes a new element adds. const ELEMENT_BYTES: usize = PROP_BYTES + 32 + 2 * 10; -/// `o[k] = v` as the array methods assign: a failure throws. +/// `o[k] = v` as the array methods assign: a failure throws. `v` stays rooted while heap is +/// reserved for it, since the caller may hold it nowhere else. fn put_at(interp: &mut Interp, o: ObjId, k: f64, v: Value) -> JsResult<()> { - interp.reserve_heap(ELEMENT_BYTES)?; - interp.put(o, key_of(k), v, true) + interp.rooted(std::slice::from_ref(&v), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.put(o, key_of(k), v.clone(), true) + }) } fn delete_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<()> { @@ -165,10 +168,13 @@ fn delete_at(interp: &mut Interp, o: ObjId, k: f64) -> JsResult<()> { } /// Defines `a[k]` as a writable, enumerable and configurable data property, as the methods that -/// build a new array do (DefineOwnProperty with `throw` false). +/// build a new array do (DefineOwnProperty with `throw` false). `v` stays rooted while heap is +/// reserved for it. fn create_at(interp: &mut Interp, a: ObjId, k: f64, v: Value) -> JsResult<()> { - interp.reserve_heap(ELEMENT_BYTES)?; - interp.define_own_property(a, key_of(k), &Desc::data(v, OPEN), false).map(drop) + interp.rooted(std::slice::from_ref(&v), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.define_own_property(a, key_of(k), &Desc::data(v.clone(), OPEN), false).map(drop) + }) } /// A new array with the given length and no elements. diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 7eda02a60..8fa0e7cf3 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -630,8 +630,8 @@ fn sort_elements(interp: &mut Interp, o: ObjId, len: u64, compare: &Value) -> Js match element(interp, o, k)? { Some(Value::Undefined) => undefined += 1, Some(v) => { - interp.reserve_heap(ELEMENT_BYTES)?; interp.temp_roots.push(v.clone()); + interp.reserve_heap(ELEMENT_BYTES)?; values.push(v); } None => {} diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 9acf2a6da..a7ae1d297 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -362,8 +362,10 @@ fn revive(interp: &mut Interp, v: Value, reviver: &Value) -> JsResult { if matches!(r, Value::Undefined) { interp.delete(o, &k, false)?; } else { - interp.reserve_heap(ELEMENT_BYTES)?; - interp.define_own_property(o, k, &Desc::data(r, OPEN), false)?; + interp.rooted(std::slice::from_ref(&r), |i| { + i.reserve_heap(ELEMENT_BYTES)?; + i.define_own_property(o, k, &Desc::data(r.clone(), OPEN), false) + })?; } } if let (Some(k), Value::Object(o)) = (top.keys.get(top.next).cloned(), &top.val) { @@ -436,7 +438,10 @@ fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { match resolve(interp, &settings, wrapper, &key(""))? { Item::Skip => return Ok(Value::Undefined), Item::Primitive(v) => write_primitive(interp, &mut w.out, &v)?, - Item::Container(o) => open(interp, &settings, &mut w, o)?, + Item::Container(o) => { + interp.temp_roots.push(Value::Object(o)); + open(interp, &settings, &mut w, o)?; + } } while !w.stack.is_empty() { step(interp, &settings, &mut w)?; @@ -585,12 +590,12 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul } /// Starts writing an object or array: a cycle back to one already being written throws a -/// `TypeError`. +/// `TypeError`. The caller pushes `o` onto the temporary roots first, and `step` pops it when the +/// object or array is closed. fn open(interp: &mut Interp, settings: &Settings, w: &mut Writer, o: ObjId) -> JsResult<()> { if w.open.contains(&o) { return Err(interp.type_error("JSON.stringify cannot write a structure that contains itself")); } - interp.temp_roots.push(Value::Object(o)); let array = interp.heap.object(o).is_some_and(|x| matches!(x.class, Class::Array { .. })); let keys = if array { let len = length_of(interp, o)?; @@ -664,6 +669,10 @@ fn step(interp: &mut Interp, settings: &Settings, w: &mut Writer) -> JsResult<() if let Some(top) = w.stack.last_mut() { top.count += 1; } + // The separator and the key reserve heap, so a container is rooted before they are written. + if let Item::Container(o) = item { + interp.temp_roots.push(Value::Object(o)); + } separator(interp, w, count == 0, gap)?; if !array { write_quoted(interp, &mut w.out, &k)?; diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index 5d108f8f6..ce8ce3a41 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -300,6 +300,63 @@ fn values_built_ins_hold_survive_collections() { assert_eq!(realm.run(src), Ok(JsValue::Number(598.0))); } +#[test] +fn fresh_values_survive_a_collection_while_they_are_stored() { + // Below 1 MiB the only collections run at the heap limit, many of them while a built-in + // reserves room for a value that a callback has just made and nothing else holds. + let limits = Limits { heap_bytes: 768 << 10, ..Limits::default() }; + let src = "var bad = 0; \ + for (var n = 0; n < 2000; n++) { \ + var m = [1, 2, 3, 4, 5, 6, 7, 8].map(function (x) { var g = {x: x}; return {v: x}; }); \ + var f = [1, 2, 3, 4].filter(function () { return true; }).map(function (x) { return [x]; }); \ + for (var i = 0; i < 8; i++) if (!m[i] || m[i].v !== i + 1) bad++; \ + for (var i = 0; i < 4; i++) if (!f[i] || f[i][0] !== i + 1) bad++; \ + } bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + // Getters make a new object for each read, which `shift` and `sort` then store. + let src = "function fresh(v) { var g = {x: v}; return {v: v}; } \ + function set(i) { return function (x) { Object.defineProperty(this, i, {value: x, writable: true, enumerable: true, configurable: true}); }; } \ + var p = {}; \ + for (var i = 0; i < 4; i++) Object.defineProperty(p, i, {get: (function (v) { return function () { return fresh(v); }; })(4 - i), set: set(i)}); \ + var bad = 0; \ + for (var n = 0; n < 1000; n++) { \ + var o = Object.create(p); o.length = 4; Array.prototype.shift.call(o); \ + if (!o[0] || o[0].v !== 3 || o[2].v !== 1) bad++; \ + var s = Object.create(p); s.length = 4; Array.prototype.sort.call(s, function (a, b) { return a.v - b.v; }); \ + for (var i = 0; i < 4; i++) if (!s[i] || s[i].v !== i + 1) bad++; \ + } \ + var q = {}; \ + for (var i = 0; i < 4; i++) Object.defineProperty(q, i, {get: (function (v) { return function () { return {v: v}; }; })(4 - i), set: function () {}}); \ + for (var n = 0; n < 20000; n++) { \ + var u = Object.create(q); u.length = 4; Array.prototype.unshift.call(u, 0, 0, 0, 0); \ + for (var i = 4; i < 8; i++) if (!u.hasOwnProperty(i) || u[i].v !== 8 - i) bad++; \ + } \ + for (var n = 0; n < 5000; n++) { var t = Object.create(q); t.length = 4; Array.prototype.sort.call(t); } \ + bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + // The `z` objects change each round's garbage, so the collections fall at different points. + let src = "var bad = 0; \ + for (var n = 0; n < 10000; n++) { \ + for (var z = 0; z < n % 7; z++) var g = {z: z}; \ + var j = JSON.parse('[1, 2, 3, 4, 5, 6, 7, 8]', function (k, v) { return k === '' ? v : {v: v}; }); \ + for (var i = 0; i < 8; i++) if (!j[i] || j[i].v !== i + 1) bad++; \ + } bad"; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); + let src = "var k = ''; for (var i = 0; i < 300; i++) k += 'k'; \ + var o = {}; o[k] = {toJSON: function () { var g = {x: 1}; return {a: 1}; }}; \ + var want = '{\"' + k + '\":{\"a\":1}}'; \ + var bad = 0; for (var n = 0; n < 4000; n++) if (JSON.stringify(o) !== want) bad++; bad"; + let mut realm = small(limits); + assert_eq!(realm.run(src), Ok(JsValue::Number(0.0))); + assert!(realm.interp().heap.collections > 0); +} + // ---- errors ---- #[test] From 3e09521b1f16d2be864f86abb9eee82d7f52944e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 17:22:06 -0800 Subject: [PATCH 29/82] JavaScript: sort keys and replacer names count toward the heap limit Array.prototype.sort without a comparison function and the property list of a JSON.stringify replacer array keep a string for each element. A key that comes from flattening a concatenation is a new buffer: it is now charged to the heap, and keys that are not elements are rooted so that a collection counts them. Keys that share an existing string's buffer are not charged again. --- crates/js/src/builtins/array.rs | 19 ++++++++++++++++--- crates/js/src/builtins/json.rs | 24 +++++++++++++++++------- crates/js/src/builtins_tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 8fa0e7cf3..1361f5599 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -641,9 +641,22 @@ fn sort_elements(interp: &mut Interp, o: ObjId, len: u64, compare: &Value) -> Js let mut keyed = Vec::with_capacity(values.len()); for v in values { interp.use_fuel()?; - let s = interp.to_str(&v)?.flat(); - interp.reserve_heap(crate::heap::string_bytes(s.len()))?; - keyed.push((s, v)); + let s = interp.to_str(&v)?; + // Flattening a concatenation makes a new buffer, so it is charged. A key that is + // not an element is rooted so that collections count it. + let rope = s.buffer_addr().is_none(); + let bytes = crate::heap::string_bytes(s.len()); + if rope { + interp.reserve_heap(bytes)?; + } + let units = s.flat(); + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + if !matches!(v, Value::String(_)) { + interp.temp_roots.push(Value::String(s)); + } + keyed.push((units, v)); } merge_sort(interp, &mut keyed, |_, a, b| Ok(a.0 > b.0))?; values = keyed.into_iter().map(|(_, v)| v).collect(); diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index a7ae1d297..93d9061dc 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -487,17 +487,27 @@ fn property_list(interp: &mut Interp, r: ObjId) -> JsResult> { interp.use_fuel()?; let v = interp.get_from(r, &key_of(f64::from(i)), &Value::Object(r))?; let item = match &v { - Value::String(s) => Some(s.flat()), - Value::Number(n) => Some(number_to_str(*n).flat()), + Value::String(s) => Some(s.clone()), + Value::Number(_) => Some(interp.primitive_to_str(&v)), Value::Object(o) if interp.heap.object(*o).is_some_and(|x| matches!(x.class, Class::String(_) | Class::Number)) => { - Some(interp.to_str(&v)?.flat()) + Some(interp.to_str(&v)?) } _ => None, }; - if let Some(k) = item - && seen.insert(k.clone()) - { - interp.reserve_heap(string_bytes(k.len()))?; + let Some(s) = item else { continue }; + // Flattening a concatenation makes a new buffer, so it is charged. + let rope = s.buffer_addr().is_none(); + let bytes = string_bytes(s.len()); + if rope { + interp.reserve_heap(bytes)?; + } + let k = s.flat(); + if seen.insert(k.clone()) { + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + // The list lives until the call returns, so its names are rooted. + interp.temp_roots.push(Value::String(s)); list.push(k); } } diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index ce8ce3a41..749454756 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -278,6 +278,32 @@ fn built_ins_end_with_a_limit_instead_of_running_on() { assert_eq!(limit(&mut realm, "var s = 'x'; for (var i = 0; i < 20; i++) s += s; JSON.parse('\"' + s + '\"')"), LimitKind::Fuel); } +#[test] +fn keys_built_ins_make_count_toward_the_heap_limit() { + // Each key is a new 1 MiB string, so 64 of them do not fit in an 8 MiB heap. + let limits = Limits { heap_bytes: 8 << 20, ..Limits::default() }; + let big = "var s = 'x'; for (var i = 0; i < 19; i++) s += s; "; + let sort = format!("{big} var a = []; for (var i = 0; i < 64; i++) a[i] = {{n: i, toString: function () {{ return s + this.n; }}}}; a.sort();"); + assert_eq!(limit(&mut small(limits.clone()), &sort), LimitKind::Heap); + let list = format!( + "{big} var a = [], c = 0; for (var i = 0; i < 64; i++) Object.defineProperty(a, i, {{get: function () {{ return s + c++; }}}}); JSON.stringify({{}}, a);" + ); + assert_eq!(limit(&mut small(limits), &list), LimitKind::Heap); + // Keys that share a string's storage are not charged again: with half the string's size to + // spare, sorting objects whose keys are that string still fits. + let setup = format!( + "{big} var keep = [s + 0, s + 1]; keep.sort(); var a = []; for (var i = 0; i < 64; i++) a[i] = s; a.sort(); \ + var b = []; for (var i = 0; i < 64; i++) b[i] = {{toString: function () {{ return s; }}}}; 0" + ); + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let live = measure.interp().heap.bytes; + let mut realm = small(Limits { heap_bytes: live + (512 << 10), ..Limits::default() }); + assert_eq!(realm.run(&setup), Ok(JsValue::Number(0.0))); + assert_eq!(realm.run("b.sort(); JSON.stringify({}, a); JSON.stringify({}, [s, s, s]); b.length"), Ok(JsValue::Number(64.0))); +} + #[test] fn values_built_ins_hold_survive_collections() { // A small heap makes the callbacks' garbage start collections while the results are built. From d21ab8f4e2748b6f6970e52ccbe0ff36011f4843 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 17:38:17 -0800 Subject: [PATCH 30/82] JavaScript: join with an empty separator does not walk the holes of a sparse array Array.prototype.join wrote the separator once per index between the elements it found, so new Array(4294967295).join("") ran four billion empty writes without taking fuel. Separators are now written in one step per gap, and an empty separator writes nothing. --- crates/js/src/builtins.rs | 14 ++++++++++++++ crates/js/src/builtins/array.rs | 8 ++------ crates/js/src/builtins_tests.rs | 2 ++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 7016e690d..40131ef07 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -227,6 +227,20 @@ impl Builder { Ok(()) } + /// Appends `units` `count` times. Nothing is written for an empty `units`, however large + /// `count` is. + fn push_repeated(&mut self, interp: &mut Interp, units: &[u16], count: u64) -> JsResult<()> { + if units.is_empty() || count == 0 { + return Ok(()); + } + let more = usize::try_from(count).unwrap_or(usize::MAX).saturating_mul(units.len()); + self.make_room(interp, more)?; + for _ in 0..count { + self.units.extend_from_slice(units); + } + Ok(()) + } + fn push_str(&mut self, interp: &mut Interp, s: &str) -> JsResult<()> { let units: Vec = s.encode_utf16().collect(); self.push_units(interp, &units) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 1361f5599..ad034c64e 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -291,15 +291,11 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( while let Some(k) = it.next(interp)? { let v = get_at(interp, o, k as f64)?; let Some(s) = item(interp, v)? else { continue }; - for _ in seps..k { - out.push_units(interp, sep)?; - } + out.push_repeated(interp, sep, k.saturating_sub(seps))?; seps = k; out.push_units(interp, &s.flat())?; } - for _ in seps..u64::from(len - 1) { - out.push_units(interp, sep)?; - } + out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; Ok(out.finish()) } diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index 749454756..f031c70dc 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -231,6 +231,8 @@ fn sparse_arrays_of_any_length_cost_only_their_elements() { let cases = [ ("var a = new Array(4294967295); a.join('')", JsValue::String(String::new())), ("var a = []; a[4294967294] = 'z'; a[3] = 'y'; a.join('').length", JsValue::Number(2.0)), + // An empty separator is not written once per hole, so a thousand such joins finish. + ("var b = []; b[4294967294] = 'z'; for (var i = 0; i < 1000; i++) new Array(4294967295).join('') + b.join(''); i", JsValue::Number(1000.0)), ("var a = []; a[4294967294] = 2; a[7] = 1; a.sort(); a[0] * 10 + a[1] + (a.length === 4294967295 ? 0 : 100)", JsValue::Number(12.0)), ("var a = []; a[4294967294] = 1; a.indexOf(1)", JsValue::Number(4_294_967_294.0)), ("var a = []; a[4294967294] = 1; a.lastIndexOf(1, -1) + a.lastIndexOf(2)", JsValue::Number(4_294_967_293.0)), From 5508f9eccc18e07194f7f96697bbe519bd500e59 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 17:38:18 -0800 Subject: [PATCH 31/82] xtask: test262 help lists the built-in tests and json2.js --- xtask/src/main.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index bfd6172ac..7d3f09804 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -44,7 +44,8 @@ commands: stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 verified), parse its ES5 subset with vectorcraft-js and run the ES5 language - tests its interpreter supports, printing the counts + and built-in tests its interpreter supports, printing the counts; then fetch + json2.js into corpus/json2 (sha256 verified) and run it web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -341,6 +342,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 verified: the JavaScript parser's and interpreter's ES5 subsets. Fetch and run: cargo xtask test262 + corpus/json2/ json2.js, the public-domain JSON polyfill, at a pinned commit, sha256 verified: + compared with vectorcraft-js's JSON. Fetch and run: cargo xtask test262 ", corpus.display() ); From e03cf7c5f7e0ef09a5c1881821ad5b6d24e65e73 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 17:45:16 -0800 Subject: [PATCH 32/82] JavaScript: defining a new property checks the heap limit first Object.defineProperty, Object.defineProperties and Object.create added each new property without checking the heap limit, so one call could take the heap estimate past the limit before the run stopped. The property's size is now reserved before it is added, with the object and the descriptor's values kept reachable while a collection runs. --- crates/js/src/builtins_tests.rs | 15 +++++++++++++++ crates/js/src/interp.rs | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index f031c70dc..ff4dcbf6d 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -306,6 +306,21 @@ fn keys_built_ins_make_count_toward_the_heap_limit() { assert_eq!(realm.run("b.sort(); JSON.stringify({}, a); JSON.stringify({}, [s, s, s]); b.length"), Ok(JsValue::Number(64.0))); } +#[test] +fn define_properties_checks_the_heap_before_each_new_property() { + // `src` takes most of the heap; copying its 20,000 names onto a new object does not fit, and + // the run ends before the heap estimate passes the limit. + let setup = "var d = {value: 1}; var src = {}; for (var i = 0; i < 20000; i++) src['k' + i] = d; 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (1 << 20); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "var o = Object.defineProperties({}, src); 0"), LimitKind::Heap); + assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes); +} + #[test] fn values_built_ins_hold_survive_collections() { // A small heap makes the callbacks' garbage start collections while the results are built. diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 57adbbb9b..1a60bc8a6 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1073,6 +1073,11 @@ impl Interp { if !o.extensible { return self.reject_bool(throw, &k, "the object is not extensible"); } + // The object and the descriptor's values stay rooted while the heap is checked. + let mut roots = vec![Value::Object(id)]; + roots.extend(desc.value.iter().cloned()); + roots.extend(desc.get.flatten().into_iter().chain(desc.set.flatten()).map(Value::Object)); + self.rooted(&roots, |i| i.reserve_heap(binding_bytes(k.len())))?; let flags = desc.flags_or(0); let prop = if desc.is_accessor() { Prop::Accessor { get: desc.get.flatten(), set: desc.set.flatten(), flags: flags & !WRITABLE } From 28bc571e3486c7508f44a9a3242cc53547475c67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 17:55:51 -0800 Subject: [PATCH 33/82] JavaScript: bind converts the target's length to an integer A host function has no own length, so a script can define one with a fraction, and the bound function's length kept it. Function.prototype.bind now converts the target's length to an integer before it subtracts the bound arguments, as later editions specify. --- crates/js/src/builtins.rs | 8 ++++++-- crates/js/src/builtins_tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 40131ef07..eba96041d 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -448,11 +448,15 @@ fn function_bind(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> J let Some(target) = this.as_object() else { return Err(interp.type_error("the value bind was called on is not a function")) }; let bound_this = arg(args, 0); let bound_args: Rc<[Value]> = args.get(1..).unwrap_or_default().into(); + // A host function's `length` is an ordinary property a script can set, so it is converted + // to an integer first, as later editions specify. let target_length = match interp.get_from(target, &key("length"), &this)? { - Value::Number(n) => n, + Value::Number(n) if n.is_nan() => 0.0, + Value::Number(n) => n.trunc(), _ => 0.0, }; - let length = (target_length - bound_args.len() as f64).max(0.0); + // Adding 0 turns a negative zero into 0. + let length = (target_length - bound_args.len() as f64).max(0.0) + 0.0; interp.reserve_heap(OBJECT_BYTES + 4 * PROP_BYTES + BOUND_ARG_BYTES * bound_args.len())?; let proto = interp.intrinsics.function_proto; let class = Class::Function(Func::Bound { target, this: bound_this, args: bound_args.clone() }); diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index ff4dcbf6d..c6179254c 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -134,6 +134,14 @@ fn bind_fixes_this_and_leading_arguments() { throws("'use strict'; function f() {} f.bind(null).caller", "TypeError"); truth("function f() { return arguments.length; } f.bind(null, 1).bind(null, 2)(3) === 3"); truth("Object.prototype.toString.call(function () {}.bind()) === '[object Function]'"); + // A host function has no own `length`, so a script can define one, and bind converts it to + // an integer. + let mut realm = Realm::new(); + realm.define_function("host", |_| Ok(JsValue::Undefined)); + let src = "function len(v) { Object.defineProperty(host, 'length', {value: v, configurable: true}); return host; } \ + var a = len(2.9).bind(null).length; var b = 1 / len(-0.5).bind(null).length; var c = len(NaN).bind(null).length; \ + [a, b, c, len(Infinity).bind(null, 1).length].join()"; + assert_eq!(realm.run(src), Ok(JsValue::String("2,Infinity,0,Infinity".into()))); } // ---- Array ---- From 60e617f8616294e18e0b31b4ad695ae6d3cb14aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 18:28:44 -0800 Subject: [PATCH 34/82] JavaScript: parseInt and parseFloat count their copies toward the heap limit parseInt collected every digit into a vector outside the heap count and then copied the digits again for a base-10 parse, and parseFloat copied its whole decimal prefix the same way, so one call on a long string could allocate more than the heap limit. parseInt now counts the digits in place, reads bases other than 10 straight from the string, and both functions reserve heap for the base-10 copy before they make it. A concatenation that the global functions flatten is charged to the heap, as sort keys are. --- crates/js/src/builtins/global.rs | 45 +++++++++++++++++++++++++------- crates/js/src/builtins_tests.rs | 27 +++++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/crates/js/src/builtins/global.rs b/crates/js/src/builtins/global.rs index 1732ce3f6..14f1b6805 100644 --- a/crates/js/src/builtins/global.rs +++ b/crates/js/src/builtins/global.rs @@ -33,13 +33,27 @@ fn trim_start(s: &[u16]) -> &[u16] { s.get(s.iter().position(|&u| !is_str_space(u)).unwrap_or(s.len())..).unwrap_or_default() } -/// ToString of the first argument, as code units, with each one taking a unit of fuel. +/// ToString of the first argument, as code units, with each one taking a unit of fuel. A +/// concatenation is flattened into a new buffer, which is charged to the heap. fn units_of(interp: &mut Interp, args: &[Value]) -> JsResult> { - let s = interp.to_str(&arg(args, 0))?.flat(); + let s = interp.to_str(&arg(args, 0))?; for _ in 0..s.len() { interp.use_fuel()?; } - Ok(s) + let rope = s.buffer_addr().is_none(); + let bytes = crate::heap::string_bytes(s.len()); + if rope { + let mark = interp.temp_roots.len(); + interp.temp_roots.push(Value::String(s.clone())); + let reserved = interp.reserve_heap(bytes); + interp.temp_roots.truncate(mark); + reserved?; + } + let units = s.flat(); + if rope { + interp.heap.bytes = interp.heap.bytes.saturating_add(bytes); + } + Ok(units) } fn digit_value(u: u16) -> Option { @@ -76,19 +90,31 @@ fn parse_int(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult rest = rest.get(2..).unwrap_or_default(); r = 16; } - let digits: Vec = rest.iter().map_while(|&u| digit_value(u).filter(|&d| d < r)).collect(); - if digits.is_empty() { + let n = rest.iter().map_while(|&u| digit_value(u).filter(|&d| d < r)).count(); + let prefix = rest.get(..n).unwrap_or_default(); + if prefix.is_empty() { return Ok(Value::Number(f64::NAN)); } + let digits = prefix.iter().filter_map(|&u| digit_value(u)); let value = match r { - 10 => digits.iter().filter_map(|&d| char::from_digit(d, 10)).collect::().parse::().unwrap_or(f64::NAN), - 2 | 4 | 8 | 16 | 32 => crate::chars::radix_value(digits.iter().copied(), r).unwrap_or(f64::NAN), + 10 => parse_decimal(interp, prefix)?, + 2 | 4 | 8 | 16 | 32 => crate::chars::radix_value(digits, r).unwrap_or(f64::NAN), // Other radices may be approximated (section 15.1.2.2, step 13). - _ => digits.iter().fold(0.0, |n, &d| n * f64::from(r) + f64::from(d)), + _ => digits.fold(0.0, |n, d| n * f64::from(r) + f64::from(d)), }; Ok(Value::Number(sign * value)) } +/// The number that `text`, ASCII decimal digits with an optional fraction and exponent, stands +/// for. The text is copied into a string of one byte per code unit for parsing, and that copy is +/// checked against the heap limit first. +fn parse_decimal(interp: &mut Interp, text: &[u16]) -> JsResult { + interp.reserve_heap(text.len())?; + let mut ascii = String::with_capacity(text.len()); + ascii.extend(text.iter().filter_map(|&u| char::from_u32(u32::from(u)))); + Ok(ascii.parse::().unwrap_or(f64::NAN)) +} + /// The length of the longest prefix of `s` that is a StrDecimalLiteral without its sign and /// without `Infinity`. fn decimal_prefix(s: &[u16]) -> usize { @@ -148,8 +174,7 @@ fn parse_float(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResu if n == 0 { return Ok(Value::Number(f64::NAN)); } - let text = String::from_utf16_lossy(rest.get(..n).unwrap_or_default()); - Ok(Value::Number(sign * text.parse::().unwrap_or(f64::NAN))) + Ok(Value::Number(sign * parse_decimal(interp, rest.get(..n).unwrap_or_default())?)) } /// `isNaN` (section 15.1.2.4). diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index c6179254c..ba5454999 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -329,6 +329,33 @@ fn define_properties_checks_the_heap_before_each_new_property() { assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes); } +#[test] +fn number_parsing_counts_its_copy_toward_the_heap_limit() { + // `s` is 1 Mi digits, so parsing it in base 10 copies 1 MiB, which does not fit in the + // 512 KiB left. Bases 16 and 36 read the digits where they are and still fit, but + // flattening a concatenation of `s` makes a 2 MiB buffer, which does not. + let setup = "var s = new Array((1 << 20) + 1).join('1'); var pad = new Array(800000).join('2'); 0"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(setup), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (512 << 10); + assert!(heap_bytes >= 4 << 20, "{heap_bytes}"); + let limits = Limits { heap_bytes, ..Limits::default() }; + let mut realm = small(limits.clone()); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!( + realm.run("parseInt(s, 16) > 0 && parseInt(s, 36) > 0 && parseInt('12') === 12 && parseFloat('1.5') === 1.5"), + Ok(JsValue::Bool(true)) + ); + assert_eq!(limit(&mut realm, "parseInt(s)"), LimitKind::Heap); + let mut realm = small(limits.clone()); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "parseInt(s + '1', 16)"), LimitKind::Heap); + let mut realm = small(limits); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + assert_eq!(limit(&mut realm, "parseFloat(s)"), LimitKind::Heap); +} + #[test] fn values_built_ins_hold_survive_collections() { // A small heap makes the callbacks' garbage start collections while the results are built. From a9e0d95226f98817b822abe2d64894090f80f737 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:00:44 -0800 Subject: [PATCH 35/82] JavaScript: assigning an array's length converts the value twice, as defining it does Section 15.4.5.1 steps 3.c and 3.d convert the new length once with ToUint32 and once with ToNumber. [[Put]] reaches that algorithm through [[DefineOwnProperty]], so a.length = v now calls v's valueOf twice, the same as Object.defineProperty(a, 'length', {value: v}). Tests count the valueOf calls on both paths, check that JSON.stringify converts Number and String objects with ToNumber and ToString, and check that toLocaleString calls each primitive element's method on the object ToObject makes. --- crates/js/src/builtins_tests.rs | 14 ++++++++++++++ crates/js/src/interp.rs | 8 ++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index ba5454999..3e8362e52 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -85,6 +85,10 @@ fn array_length_and_index_definitions_follow_section_15_4_5_1() { truth("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); a[5] = 1; a.length === 1 && !(5 in a)"); throws("var a = [1]; Object.defineProperty(a, 'length', {writable: false}); Object.defineProperty(a, '3', {value: 1});", "TypeError"); throws("Object.defineProperty([], 'length', {value: -1})", "RangeError"); + // Steps 3.c and 3.d convert the new length once with ToUint32 and once with ToNumber. + let counted = "var calls = 0; var len = {valueOf: function () { calls++; return 2; }}; var a = [1, 2, 3];"; + assert_eq!(num(&format!("{counted} Object.defineProperty(a, 'length', {{value: len}}); calls * 10 + a.length")), 22.0); + assert_eq!(num(&format!("{counted} a.length = len; calls * 10 + a.length")), 22.0); truth("var a = []; Object.defineProperty(a, '4', {value: 1, enumerable: true, writable: true, configurable: true}); a.length === 5"); throws("var a = Object.freeze([1, 2]); a.push(3);", "TypeError"); } @@ -229,6 +233,13 @@ fn join_and_to_string_are_cycle_safe() { assert_eq!(string("[null, undefined, 0].join('-')"), "--0"); assert_eq!(string("Array.prototype.toString.call({join: 1})"), "[object Object]"); assert_eq!(string("var a = [{toLocaleString: function () { return 'L'; }}, null]; a.push(a); a.toLocaleString()"), "L,,"); + // A primitive element is converted with ToObject and its toLocaleString is called on the object. + assert_eq!( + string( + "Object.getPrototypeOf(Object(1)).toLocaleString = function () { 'use strict'; return typeof this; }; [1, undefined, 2].toLocaleString()" + ), + "object,,object" + ); } #[test] @@ -501,6 +512,9 @@ fn json_stringify_writes_values_with_replacers_and_indentation() { assert_eq!(string("JSON.stringify({toJSON: function (k) { return 'k=' + k; }})"), "\"k=\""); assert_eq!(string("JSON.stringify({d: {toJSON: function (k) { return k; }}})"), "{\"d\":\"d\"}"); assert_eq!(string("JSON.stringify([Object(false), Object(true)])"), "[false,true]"); + // Number and String objects go through ToNumber and ToString, which call valueOf and toString. + assert_eq!(string("var n = Object(5); n.valueOf = function () { return 7; }; JSON.stringify([n])"), "[7]"); + assert_eq!(string("var s = Object('x'); s.toString = function () { return 'y'; }; JSON.stringify({s: s})"), "{\"s\":\"y\"}"); assert_eq!(string("JSON.stringify({}, null, 4) + JSON.stringify([], null, 4)"), "{}[]"); throws("var a = []; a.push(a); JSON.stringify(a)", "TypeError"); throws("var o = {}; o.x = {y: o}; JSON.stringify(o)", "TypeError"); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 1a60bc8a6..5133b597f 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1272,11 +1272,11 @@ impl Interp { } } - /// Assigning to an array's `length` (section 15.4.5.1). + /// Assigning to an array's `length` (section 15.4.5.1). [[Put]] defines the value, so the + /// value is converted once with ToUint32 and once with ToNumber, as in steps 3.c and 3.d. fn set_array_length(&mut self, id: ObjId, v: &Value, throw: bool) -> JsResult<()> { - let n = self.to_number(v)?; - let new = to_uint32(n); - if f64::from(new) != n { + let new = to_uint32(self.to_number(v)?); + if f64::from(new) != self.to_number(v)? { return Err(self.throw(ErrorType::Range, "invalid array length")); } let Class::Array { length_writable, .. } = self.object(id)?.class else { return internal("not an array") }; From 2d13f219f689d9ee8e5911b718d4a30a720efd97 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:23:56 -0800 Subject: [PATCH 36/82] JavaScript: getOwnPropertyNames lists an array's length after its other names Section 15.2.3.4 leaves the order of the names open. An array's length now comes after its indices and other properties, so Object.getOwnPropertyNames([0, 1, 2]) gives 0,1,2,length, as later editions and test262 expect. length is not enumerable, so Object.keys and for-in are unchanged. --- crates/js/src/builtins_tests.rs | 3 ++- crates/js/src/interp.rs | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index 3e8362e52..f95569749 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -100,7 +100,8 @@ fn object_functions_list_create_and_lock_objects() { ); truth("Object.getPrototypeOf(Object.create(null)) === null && Object.getPrototypeOf([]) === Array.prototype"); assert_eq!(string("Object.getOwnPropertyNames(Object('ab')).join()"), "0,1,length"); - assert_eq!(string("var a = [5]; a.k = 1; Object.getOwnPropertyNames(a).join()"), "length,0,k"); + assert_eq!(string("Object.getOwnPropertyNames([0, 1, 2]).join()"), "0,1,2,length"); + assert_eq!(string("var a = [5]; a.k = 1; Object.getOwnPropertyNames(a).join()"), "0,k,length"); // Object.keys lists names in the order for-in visits them (section 15.2.3.14). assert_eq!(string("var o = {b: 1, a: 2, 3: 3}, s = ''; for (var k in o) s += k; Object.keys(o).join('') === s ? s : 'differs'"), "ba3"); truth("var o = Object.seal({x: 1}); o.x = 2; delete o.x; o.y = 1; o.x === 2 && o.y === undefined && Object.isSealed(o) && !Object.isFrozen(o)"); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 096ad99e7..2293984ce 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -1173,27 +1173,31 @@ impl Interp { } /// The names of an object's own properties (section 15.2.3.4): a String object's indices and - /// an array's or String object's `length` first, then the rest in the order they were added, - /// which is the order `for-in` visits them. Each name takes a unit of fuel, and the heap for - /// the list is checked first. + /// `length` first, then the rest in the order they were added, which is the order `for-in` + /// visits them, then an array's `length`. Each name takes a unit of fuel, and the heap for the + /// list is checked first. pub(crate) fn own_keys(&mut self, id: ObjId) -> JsResult> { let o = self.object(id)?; let string_len = if let Class::String(s) = &o.class { s.len() } else { 0 }; - let has_length = matches!(o.class, Class::Array { .. } | Class::String(_)); - let count = o.props.len().saturating_add(string_len).saturating_add(usize::from(has_length)); + let is_string = matches!(o.class, Class::String(_)); + let is_array = matches!(o.class, Class::Array { .. }); + let count = o.props.len().saturating_add(string_len).saturating_add(usize::from(is_string || is_array)); self.reserve_heap(index_keys_bytes(string_len).saturating_add(count.saturating_mul(KEY_ENTRY_BYTES)))?; let mut out = Vec::with_capacity(count); for i in 0..string_len { self.use_fuel()?; out.push(index_key(u32::try_from(i).unwrap_or(u32::MAX))); } - if has_length { + if is_string { out.push(key("length")); } for _ in 0..self.object(id)?.props.len() { self.use_fuel()?; } out.extend(self.object(id)?.props.keys()); + if is_array { + out.push(key("length")); + } Ok(out) } From 9d528621f7d0ae98a206d7f7b4519657d2c062a6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:24:49 -0800 Subject: [PATCH 37/82] Docs: built-in test262 counts after the array length order change --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16e088312..e3e65fd16 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -655,7 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next entry lists. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 3,362 of 3,387 runs pass (4 of the others expect the completion values of later editions, and 21 call `String` or `Number` methods). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects and visit only the indices that exist, so `new Array(4294967295).join("")` ends at once; `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 4,478 of 4,556 runs pass (16 of the failing tests call `String`, `Number` or `Boolean` methods, and 23 expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit to compare with the built-in `JSON`; that test runs once `Date`, `String.prototype.replace` and `RegExp` exist. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. + - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects and visit only the indices that exist, so `new Array(4294967295).join("")` ends at once; `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 4,480 of 4,556 runs pass (16 of the failing tests call `String`, `Number` or `Boolean` methods, and 22 expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit to compare with the built-in `JSON`; that test runs once `Date`, `String.prototype.replace` and `RegExp` exist. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From 65691ceb7b73e8c57da27cab30e679b639d675d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:27:44 -0800 Subject: [PATCH 38/82] JavaScript: unshift with no arguments reads and writes each element Section 15.4.4.13 steps 6 and 7 move every element by the argument count, so with no arguments each element is read and put back in place: getters and setters run, and a read-only element throws a TypeError. --- crates/js/src/builtins/array.rs | 11 +++++------ crates/js/src/builtins_tests.rs | 6 ++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index ad034c64e..98a19ff0a 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -469,12 +469,11 @@ fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul with_this(interp, &this, |interp, o| { let len = u64::from(length_of(interp, o)?); let count = args.len() as u64; - if count > 0 { - let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; - let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; - while let Some(k) = it.next(interp)? { - move_element(interp, o, k - 1, k + count - 1)?; - } + // With no arguments each element is still read and written back in place (steps 6 and 7). + let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; + let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; + while let Some(k) = it.next(interp)? { + move_element(interp, o, k - 1, k + count - 1)?; } for (j, v) in args.iter().enumerate() { interp.use_fuel()?; diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index f95569749..aaf2d245a 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -167,6 +167,12 @@ fn methods_that_change_the_array() { assert_eq!(string("var a = [1, 2, 3]; a.pop() + ':' + a.join() + ':' + [].pop()"), "3:1,2:undefined"); assert_eq!(string("var a = [1, 2, 3]; a.shift() + ':' + a.join()"), "1:2,3"); assert_eq!(string("var a = [3]; a.unshift(1, 2) + ':' + a.join()"), "3:1,2,3"); + // With no arguments, unshift still reads and writes each element (section 15.4.4.13). + assert_eq!( + run("var n = 0, a = []; Object.defineProperty(a, 0, {get: function () { n++; return 1; }, set: function () { n += 10; }}); a.unshift(); n"), + JsValue::Number(11.0) + ); + throws("var a = [1, 2]; Object.defineProperty(a, 0, {writable: false}); a.unshift()", "TypeError"); assert_eq!(string("[1, , 3].reverse().join()"), "3,,1"); truth("var a = [1, , 3, , ]; a.reverse(); !(0 in a) && a[1] === 3 && !(2 in a) && a[3] === 1 && a.length === 4"); assert_eq!(string("var a = [1, 2, 3, 4, 5]; var r = a.splice(1, 2, 'x'); r.join() + ':' + a.join()"), "2,3:1,x,4,5"); From 447a3cc65febe60c58a3e8f5b6cc84a9a3f23f0f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:46:01 -0800 Subject: [PATCH 39/82] JSON: stringify unwraps Number and String objects to their primitive values Number objects made by ToObject now carry their primitive value, as String and Boolean objects already did (ECMA-262 5.1 sections 8.6.2 and 9.9). JSON.stringify reads it for a value, a replacer list entry and the space argument (section 15.12.3, Str step 4, step 4.b.ii and step 5) when the method that ToNumber or ToString calls first is not found before the object's own prototype, so JSON.stringify(Object(3)) gives 3 and JSON.stringify(Object('abc')) gives "abc". A valueOf or toString on the object or on its prototype still runs. --- crates/js/src/builtins.rs | 2 +- crates/js/src/builtins/json.rs | 66 ++++++++++++++++++++++++--------- crates/js/src/builtins_tests.rs | 19 ++++++++++ crates/js/src/heap.rs | 6 +-- crates/js/src/interp.rs | 2 +- 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 30bc3be1b..c72382e27 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -78,7 +78,7 @@ fn try_install(interp: &mut Interp) -> JsResult<()> { } interp.intrinsics.array_proto = alloc(interp, Class::Array { length: 0, length_writable: true }, Some(object_proto)); interp.intrinsics.string_proto = alloc(interp, Class::String(Str::from("")), Some(object_proto)); - interp.intrinsics.number_proto = alloc(interp, Class::Number, Some(object_proto)); + interp.intrinsics.number_proto = alloc(interp, Class::Number(0.0), Some(object_proto)); interp.intrinsics.boolean_proto = alloc(interp, Class::Boolean(false), Some(object_proto)); interp.intrinsics.regexp_proto = alloc(interp, Class::Ordinary, Some(object_proto)); let thrower = native(interp, "", throw_type_error, 0, NativeKind::Plain, false)?; diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 93d9061dc..4aa5c9ac5 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; -use crate::interp::{Desc, ErrorType, Interp, JsResult}; +use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; use crate::value::{Str, Value, number_to_str}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -459,14 +459,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult match interp.heap.object(*o).map(|x| &x.class) { - Some(Class::Number) => Value::Number(interp.to_number(space)?), - Some(Class::String(_)) => Value::String(interp.to_str(space)?), - _ => space.clone(), - }, - _ => space.clone(), - }; + let space = unwrap_primitive(interp, space, false)?.unwrap_or_else(|| space.clone()); match space { Value::Number(_) => { let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; @@ -489,9 +482,10 @@ fn property_list(interp: &mut Interp, r: ObjId) -> JsResult> { let item = match &v { Value::String(s) => Some(s.clone()), Value::Number(_) => Some(interp.primitive_to_str(&v)), - Value::Object(o) if interp.heap.object(*o).is_some_and(|x| matches!(x.class, Class::String(_) | Class::Number)) => { - Some(interp.to_str(&v)?) - } + Value::Object(_) => match unwrap_primitive(interp, &v, true)? { + Some(Value::String(s)) => Some(s), + _ => None, + }, _ => None, }; let Some(s) = item else { continue }; @@ -546,12 +540,11 @@ fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &K keep(interp, &v); } if let Value::Object(o) = &v { - v = match interp.heap.object(*o).map(|x| &x.class) { - Some(Class::Number) => Value::Number(interp.to_number(&v)?), - Some(Class::String(_)) => Value::String(interp.to_str(&v)?), - Some(Class::Boolean(b)) => Value::Bool(*b), - _ => v, - }; + if let Some(Class::Boolean(b)) = interp.heap.object(*o).map(|x| &x.class) { + v = Value::Bool(*b); + } else if let Some(p) = unwrap_primitive(interp, &v, false)? { + v = p; + } } Ok(match v { Value::Undefined => Item::Skip, @@ -561,6 +554,43 @@ fn resolve_rooted(interp: &mut Interp, settings: &Settings, holder: ObjId, k: &K }) } +/// ToNumber of a Number object, or ToString of a String object (of either when `as_string` is +/// set), as section 15.12.3 applies them in Str step 4, step 4.b.ii and step 5. Returns `None` +/// for any other value. When the method that [[DefaultValue]] (section 8.12.8) calls first is not +/// found on the object or on its prototype chain before the object's standard prototype, the +/// result is the object's [[PrimitiveValue]] (sections 8.6.2 and 9.9), the value that +/// `Number.prototype.valueOf` (15.7.4.4), `Number.prototype.toString` (15.7.4.2) and +/// `String.prototype.toString` (15.5.4.2) return. Otherwise ToPrimitive calls the method found. +fn unwrap_primitive(interp: &mut Interp, v: &Value, as_string: bool) -> JsResult> { + let Value::Object(o) = v else { return Ok(None) }; + let (primitive, proto, string) = match interp.heap.object(*o).map(|x| &x.class) { + Some(Class::Number(n)) => (Value::Number(*n), interp.intrinsics.number_proto, as_string), + Some(Class::String(s)) => (Value::String(s.clone()), interp.intrinsics.string_proto, true), + _ => return Ok(None), + }; + let (hint, name) = if string { (Hint::String, "toString") } else { (Hint::Number, "valueOf") }; + let p = if reaches_prototype(interp, *o, proto, name)? { primitive } else { interp.to_primitive(v, hint)? }; + Ok(Some(if string { Value::String(interp.primitive_to_str(&p)) } else { Value::Number(interp.to_number(&p)?) })) +} + +/// Whether looking up `name` from `o` reaches the object `proto` on the prototype chain before +/// any object on the way, `proto` included, has its own property `name`. +fn reaches_prototype(interp: &mut Interp, o: ObjId, proto: ObjId, name: &str) -> JsResult { + let k = key(name); + let mut cur = Some(o); + while let Some(id) = cur { + interp.use_fuel()?; + if interp.get_own_property(id, &k)?.is_some() { + return Ok(false); + } + if id == proto { + return Ok(true); + } + cur = interp.heap.object(id).and_then(|x| x.proto); + } + Ok(false) +} + /// Quote (section 15.12.3): the string in double quotes with the characters JSON escapes. fn write_quoted(interp: &mut Interp, out: &mut Builder, s: &[u16]) -> JsResult<()> { out.push_units(interp, &[QUOTE])?; diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index aaf2d245a..a9184c0c3 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -529,6 +529,25 @@ fn json_stringify_writes_values_with_replacers_and_indentation() { assert_eq!(string("var x = {}; JSON.stringify([x, x])"), "[{},{}]"); } +#[test] +fn json_stringify_unwraps_number_string_and_boolean_objects() { + assert_eq!(string("JSON.stringify(Object(3))"), "3"); + assert_eq!(string("JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("JSON.stringify(Object(true))"), "true"); + assert_eq!(string("JSON.stringify({n: Object(-0), m: Object(NaN), s: Object('')})"), "{\"n\":0,\"m\":null,\"s\":\"\"}"); + assert_eq!(string("JSON.stringify([Object.getPrototypeOf(Object(3)), Object.getPrototypeOf(Object('abc'))])"), "[0,\"\"]"); + // The space argument (step 5). + assert_eq!(string("JSON.stringify([1], null, Object(2))"), "[\n 1\n]"); + assert_eq!(string("JSON.stringify([1], null, Object('--'))"), "[\n--1\n]"); + // Replacer list entries (step 4.b.ii). + assert_eq!(string("JSON.stringify({a: 1, 3: 2, b: 3}, [Object('a'), Object(3)])"), "{\"a\":1,\"3\":2}"); + // Methods on Object.prototype do not reach the objects; methods on their own prototypes do. + assert_eq!(string("Object.prototype.toString = function () { return 'z'; }; JSON.stringify(Object('abc'))"), "\"abc\""); + assert_eq!(string("Object.prototype.valueOf = function () { return 9; }; JSON.stringify(Object(3))"), "3"); + assert_eq!(string("Object.getPrototypeOf(Object(1)).valueOf = function () { return 4; }; JSON.stringify(Object(3))"), "4"); + assert_eq!(string("Object.getPrototypeOf(Object('')).toString = function () { return 'q'; }; JSON.stringify(Object('abc'))"), "\"q\""); +} + #[test] fn deep_json_nesting_needs_no_native_stack() { let handle = std::thread::Builder::new() diff --git a/crates/js/src/heap.rs b/crates/js/src/heap.rs index 66dc00b24..143de10c3 100644 --- a/crates/js/src/heap.rs +++ b/crates/js/src/heap.rs @@ -209,8 +209,8 @@ pub(crate) enum Class { Arguments(Box), /// A `Boolean` object made by ToObject, with its primitive value. Boolean(bool), - /// A `Number` object made by ToObject. - Number, + /// A `Number` object made by ToObject, with its primitive value. + Number(f64), String(Str), RegExp, ForIn(Box), @@ -232,7 +232,7 @@ impl Class { Class::Error => "Error", Class::Arguments(_) => "Arguments", Class::Boolean(_) => "Boolean", - Class::Number => "Number", + Class::Number(_) => "Number", Class::String(_) => "String", Class::RegExp => "RegExp", } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 2293984ce..472d21c19 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2240,7 +2240,7 @@ impl Interp { Value::Object(o) => return Ok(*o), Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), - Value::Number(_) => (Class::Number, self.intrinsics.number_proto), + Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) From edfd25e26c49507715152cd8e84ec5425bc1476d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 19:58:05 -0800 Subject: [PATCH 40/82] JavaScript: join reads length before it converts the separator Array.prototype.join now reads and converts the object's length before it converts the separator, and still converts the separator when the length is 0, as ECMA-262 5.1 section 15.4.4.5 steps 2 to 6 give them. --- crates/js/src/builtins/array.rs | 25 ++++++++++++++----------- crates/js/src/builtins_tests.rs | 9 +++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 98a19ff0a..e3f2ebe00 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -262,16 +262,24 @@ fn to_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult }) } -/// Joins the elements `0..len` of `o`, each turned into a string by `item`, with `sep` between -/// them. A cycle back to an object being joined joins as an empty string. -fn join_with(interp: &mut Interp, o: ObjId, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { +/// Joins the elements `0..len` of `o`, each turned into a string by `item`, with `separator` +/// between them (`,` when it is `undefined`). `length` is read before the separator is converted, +/// and the separator is converted even when `length` is 0 (section 15.4.4.5 steps 2 to 6). A cycle +/// back to an object being joined joins as an empty string. +fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { if interp.join_stack.contains(&o) { return Ok(Value::str("")); } let len = length_of(interp, o)?; + let sep = match separator { + Value::Undefined => Str::from(","), + v => interp.to_str(v)?, + }; if len == 0 { return Ok(Value::str("")); } + let flat = sep.flat(); + let sep = &flat[..]; // A result that cannot fit is refused before any element is read. let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); if least > (interp.limits.heap_bytes / 4) as u64 { @@ -321,18 +329,13 @@ fn locale_item(interp: &mut Interp, v: Value) -> JsResult> { /// `Array.prototype.join` (section 15.4.4.5). fn join(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult { - with_this(interp, &this, |interp, o| { - let sep = match arg(args, 0) { - Value::Undefined => Str::from(","), - v => interp.to_str(&v)?, - }; - join_with(interp, o, &sep.flat(), join_item) - }) + let separator = arg(args, 0); + with_this(interp, &this, |interp, o| join_with(interp, o, &separator, join_item)) } /// `Array.prototype.toLocaleString` (section 15.4.4.3). fn to_locale_string(interp: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { - with_this(interp, &this, |interp, o| join_with(interp, o, &[u16::from(b',')], locale_item)) + with_this(interp, &this, |interp, o| join_with(interp, o, &Value::Undefined, locale_item)) } // ---- methods that change the object ---- diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index a9184c0c3..db483e652 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -247,6 +247,15 @@ fn join_and_to_string_are_cycle_safe() { ), "object,,object" ); + // `length` is read before the separator is converted, and the separator is converted even + // when `length` is 0 (section 15.4.4.5 steps 2 to 6). + assert_eq!( + string( + "var log = ''; Array.prototype.join.call({length: {valueOf: function () { log += 'L'; return 0; }}}, {toString: function () { log += 'S'; return ','; }}); log" + ), + "LS" + ); + assert_eq!(string("var log = ''; try { [].join({toString: function () { throw 1; }}); } catch (e) { log = 'threw ' + e; } log"), "threw 1"); } #[test] From f16cf72f07b31bdd04cf84f8d4257d9f89b40a19 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 20:09:09 -0800 Subject: [PATCH 41/82] JavaScript: strings the built-ins build take fuel and stay counted in collections The buffer that join, JSON.stringify, the URI functions, escape and unescape write into takes one unit of fuel for every 16 code units written, so writing a long run of separators or copying a long string over and over ends with the fuel limit. Its bytes are added back to the heap total after each collection until the string is finished, and a native call that ends early gives them back, so a collection while a string is being built no longer lets the heap estimate drop below what is in use. --- crates/js/src/builtins.rs | 35 +++++++++++++++++++++++++------- crates/js/src/builtins/array.rs | 2 +- crates/js/src/builtins/global.rs | 8 ++++---- crates/js/src/builtins/json.rs | 2 +- crates/js/src/builtins_tests.rs | 30 +++++++++++++++++++++++++++ crates/js/src/interp.rs | 13 +++++++++++- 6 files changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index c72382e27..4fc1adc87 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -192,30 +192,49 @@ fn new_string(interp: &mut Interp, units: Vec) -> JsResult { Ok(Value::String(Str::from_units(units))) } -/// A string the built-ins build piece by piece: each piece is checked against the longest string -/// the heap limit allows and its bytes are reserved before it is added. Everything the caller -/// still needs must be reachable from the roots. +/// Code units a built-in may write into a string for one unit of fuel. +const UNITS_PER_FUEL: usize = 16; + +/// A string the built-ins build piece by piece: each piece takes fuel for its length, is checked +/// against the longest string the heap limit allows, and has its bytes reserved before it is +/// added. The buffer's bytes count toward the heap until `finish` or until the native call that +/// made the builder returns. Everything the caller still needs must be reachable from the roots. struct Builder { units: Vec, /// Code units already reserved and charged to the heap. charged: usize, + /// Code units written since fuel was last taken, fewer than `UNITS_PER_FUEL`. + unpaid: usize, } impl Builder { fn new() -> Self { - Builder { units: Vec::new(), charged: 0 } + Builder { units: Vec::new(), charged: 0, unpaid: 0 } } - /// Checks that `more` code units fit, reserving heap for them. + /// Checks that `more` code units fit, reserving heap for them, and takes fuel for writing them. fn make_room(&mut self, interp: &mut Interp, more: usize) -> JsResult<()> { let want = self.units.len().saturating_add(more); if want > interp.limits.heap_bytes / 4 { return Err(Abrupt::Limit(crate::LimitKind::Heap)); } + self.unpaid += more; + let fuel = (self.unpaid / UNITS_PER_FUEL) as u64; + self.unpaid %= UNITS_PER_FUEL; + if fuel > 0 { + if interp.fuel < fuel { + interp.fuel = 0; + return Err(Abrupt::Limit(crate::LimitKind::Fuel)); + } + // The last unit goes through `use_fuel`, which also checks for an interrupt. + interp.fuel -= fuel - 1; + interp.use_fuel()?; + } if want > self.charged { let grow = (want - self.charged).max(self.charged / 2).max(256).min((interp.limits.heap_bytes / 4).saturating_sub(self.charged)); interp.reserve_heap(2 * grow)?; interp.heap.bytes = interp.heap.bytes.saturating_add(2 * grow); + interp.held_bytes = interp.held_bytes.saturating_add(2 * grow); self.charged += grow; } Ok(()) @@ -246,8 +265,10 @@ impl Builder { self.push_units(interp, &units) } - /// The string built, whose bytes were charged as it grew. - fn finish(self) -> Value { + /// The string built, whose bytes were charged as it grew. From here on the heap counts them + /// as the string's. + fn finish(self, interp: &mut Interp) -> Value { + interp.held_bytes = interp.held_bytes.saturating_sub(2 * self.charged); Value::String(Str::from_units(self.units)) } } diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index e3f2ebe00..2f81bf766 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -304,7 +304,7 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( out.push_units(interp, &s.flat())?; } out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; - Ok(out.finish()) + Ok(out.finish(interp)) } /// An element as `join` writes it: nothing for `undefined` and `null`. diff --git a/crates/js/src/builtins/global.rs b/crates/js/src/builtins/global.rs index 14f1b6805..261bfa146 100644 --- a/crates/js/src/builtins/global.rs +++ b/crates/js/src/builtins/global.rs @@ -241,7 +241,7 @@ fn encode(interp: &mut Interp, args: &[Value], unescaped: fn(u16) -> bool) -> Js } k += 1; } - Ok(out.finish()) + Ok(out.finish(interp)) } fn hex_byte(s: &[u16], at: usize) -> Option { @@ -294,7 +294,7 @@ fn decode(interp: &mut Interp, args: &[Value], reserved: &str) -> JsResult JsResult { @@ -329,7 +329,7 @@ fn escape(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult JsResult< out.push_units(interp, &[unit])?; k += 1; } - Ok(out.finish()) + Ok(out.finish(interp)) } diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 4aa5c9ac5..8061379ce 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -446,7 +446,7 @@ fn stringify_rooted(interp: &mut Interp, args: &[Value]) -> JsResult { while !w.stack.is_empty() { step(interp, &settings, &mut w)?; } - Ok(w.out.finish()) + Ok(w.out.finish(interp)) } /// Reads the replacer and the space argument (section 15.12.3, steps 4 to 8). diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index db483e652..4a4c2aea4 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -383,6 +383,36 @@ fn number_parsing_counts_its_copy_toward_the_heap_limit() { assert_eq!(limit(&mut realm, "parseFloat(s)"), LimitKind::Heap); } +#[test] +fn strings_built_ins_write_take_fuel_and_stay_counted_across_collections() { + // Each join writes 1 Mi code units, so a thousand of them do not fit in 2,000,000 units of + // fuel, whether the units are separators or a string's contents. + let limits = Limits { heap_bytes: 8 << 20, fuel: 2_000_000, ..Limits::default() }; + assert_eq!(limit(&mut small(limits.clone()), "var a = new Array((1 << 20) + 1); for (var i = 0; i < 1000; i++) a.join('x'); i"), LimitKind::Fuel); + assert_eq!( + limit(&mut small(limits), "var a = [new Array((1 << 20) + 1).join('x')]; for (var i = 0; i < 1000; i++) a.join(''); i"), + LimitKind::Fuel + ); + // `join` holds a 2 MiB buffer for `s` while the second element's `toString` keeps new objects. + // A collection in between still counts the buffer, so the objects and the buffer together do + // not fit in a heap with 1 MiB to spare beyond the objects. + let setup = "var s = new Array((1 << 20) + 1).join('x'); var keep = []; 0"; + let fill = "for (var i = 0; i < 30000; i++) keep.push({});"; + let mut measure = small(Limits::default()); + assert_eq!(measure.run(&format!("{setup}; {fill} 0")), Ok(JsValue::Number(0.0))); + measure.interp().collect_garbage(); + let heap_bytes = measure.interp().heap.bytes + (1 << 20); + let mut realm = small(Limits { heap_bytes, ..Limits::default() }); + assert_eq!(realm.run(setup), Ok(JsValue::Number(0.0))); + let join = format!("var f = {{toString: function () {{ {fill} return ''; }}}}; [s, f].join('').length"); + assert_eq!(limit(&mut realm, &join), LimitKind::Heap); + // A join that throws gives its buffer back, so a hundred of them still fit in 8 MiB. + let mut realm = small(Limits { heap_bytes: 8 << 20, ..Limits::default() }); + let src = "var s = new Array((1 << 20) + 1).join('x'); var t = {toString: function () { throw 1; }}; \ + for (var i = 0; i < 100; i++) { try { [s, t].join(''); } catch (e) {} } i"; + assert_eq!(realm.run(src), Ok(JsValue::Number(100.0))); +} + #[test] fn values_built_ins_hold_survive_collections() { // A small heap makes the callbacks' garbage start collections while the results are built. diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 472d21c19..7168f9dad 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -166,6 +166,10 @@ pub(crate) struct Interp { /// The objects `Array.prototype.join` and `toLocaleString` are joining, innermost last: a /// cycle back to one of them joins as an empty string. pub(crate) join_stack: Vec, + /// Bytes of the string buffers native functions are building outside the heap. They are + /// charged to `heap.bytes` and added back after each collection, and a native call's + /// buffers are released when it returns. + pub(crate) held_bytes: usize, } /// The least heap size a collection waits for. @@ -302,6 +306,7 @@ impl Interp { last_thrown: Value::Undefined, random: random_seed(), join_stack: Vec::new(), + held_bytes: 0, }; crate::builtins::install(&mut interp); interp.gc_threshold = interp.next_threshold(); @@ -455,6 +460,7 @@ impl Interp { roots.objects.extend(self.intrinsics.all()); roots.envs.push(self.global_env); self.heap.collect(&roots); + self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes); } /// Runs frames until the boundary frame on top returns, and returns its value. @@ -1733,7 +1739,9 @@ impl Interp { let mark = self.temp_roots.len(); self.temp_roots.push(this.clone()); self.temp_roots.extend(args.iter().cloned()); + let held = self.held_bytes; let r = native(self, this, &args, false); + self.held_bytes = held; self.temp_roots.truncate(mark); return Ok(Prepared::Done(r?)); } @@ -1834,7 +1842,10 @@ impl Interp { self.enter_function(Entry { callee, code, env, this: Value::Object(obj), args }, Some(obj), false) } Some(Func::Native { f: native, constructor: true, .. }) => { - let v = native(self, Value::Undefined, &args, true)?; + let held = self.held_bytes; + let r = native(self, Value::Undefined, &args, true); + self.held_bytes = held; + let v = r?; self.stack.truncate(start); self.push(v) } From fd94ebe86c38addfd277f1cc97d3b349f1595f6d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 23:05:09 -0800 Subject: [PATCH 42/82] Plug-ins: importer plug-ins open file types VectorCraft does not read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An importer plug-in (kind "importer", permission import.read) lists 1 to 8 file extensions and turns a file's bytes into objects, which open as a new document whose artboard is their bounds. File › Open lists its extensions, File › Open With (file.openWith) opens a file with a chosen importer, and document.open takes importer and params. A built-in format that reads the file by its content or extension always opens it; among importers listing the same extension, the one whose id sorts first opens it. An importer reads at most 128 MiB, and Place still reads only the built-in formats. Docs: docs/plugins.md, docs/mcp.md, docs/gaps.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio.rs | 7 +- crates/engine/src/cmd/fileio/action_batch.rs | 4 +- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/formats.rs | 84 ++++-- crates/engine/src/cmd/fileio/importer.rs | 115 ++++++++ crates/engine/src/cmd/fileio/load.rs | 15 +- crates/engine/src/cmd/plugin.rs | 22 +- crates/engine/tests/plugin_fuzz.rs | 26 +- crates/engine/tests/plugin_import.rs | 252 ++++++++++++++++++ crates/plugins/src/import.rs | 182 +++++++++++++ crates/plugins/src/lib.rs | 6 +- crates/plugins/src/manifest.rs | 52 +++- crates/plugins/src/permissions.rs | 19 +- crates/plugins/src/wat.rs | 5 + crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/dialogs/plugin.rs | 16 +- .../src/dialogs/tests_plugin_import.rs | 103 +++++++ crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 17 ++ crates/ui-egui/src/i18n/cs.tsv | 17 ++ crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 17 ++ crates/ui-egui/src/i18n/el.tsv | 17 ++ crates/ui-egui/src/i18n/es.tsv | 17 ++ crates/ui-egui/src/i18n/fi.tsv | 17 ++ crates/ui-egui/src/i18n/fr.tsv | 17 ++ crates/ui-egui/src/i18n/hi.tsv | 18 +- crates/ui-egui/src/i18n/hu.tsv | 17 ++ crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 17 ++ crates/ui-egui/src/i18n/ja.tsv | 17 ++ crates/ui-egui/src/i18n/ka.tsv | 17 ++ crates/ui-egui/src/i18n/ko.tsv | 17 ++ crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 17 ++ crates/ui-egui/src/i18n/pt-br.tsv | 17 ++ crates/ui-egui/src/i18n/ru.tsv | 17 ++ crates/ui-egui/src/i18n/sv.tsv | 18 +- crates/ui-egui/src/i18n/tr.tsv | 17 ++ crates/ui-egui/src/i18n/uk.tsv | 17 ++ crates/ui-egui/src/i18n/vi.tsv | 17 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 17 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 17 ++ crates/ui-egui/src/io.rs | 2 +- crates/ui-egui/src/io/open.rs | 67 +++++ crates/ui-egui/src/menus/labels.rs | 11 +- crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/picks.rs | 1 + docs/gaps.md | 9 +- docs/mcp.md | 16 +- docs/plugins.md | 70 +++-- docs/target-app-parity.md | 1 + 55 files changed, 1413 insertions(+), 87 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/importer.rs create mode 100644 crates/engine/tests/plugin_import.rs create mode 100644 crates/plugins/src/import.rs create mode 100644 crates/ui-egui/src/dialogs/tests_plugin_import.rs diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 0c888d13c..9903ba4cb 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,8 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no +//! built-in format reads). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -30,6 +32,7 @@ mod encode; pub mod eps; mod export; pub(crate) mod imagemap; +mod importer; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; @@ -128,8 +131,8 @@ use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, - file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, read_extensions, - readable_format, readable_format_for_name, titled_for_export, writable_format, + file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, open_filters, place_filters, + plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index e11b6adcd..5e2178cdc 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -516,12 +516,12 @@ fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result bool { - use super::{FORMATS, OPEN_EXTS, extension}; + use super::{FORMATS, extension, open_extensions}; let ext = extension(path); if ext.is_empty() { return false; } - OPEN_EXTS.iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext)) + open_extensions().iter().any(|e| *e == ext) || FORMATS.iter().any(|f| f.read && f.extensions.iter().any(|e| *e == ext)) } #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 011d025e3..ec7e303c6 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "File Formats", [], None, - "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", + "{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in (write only) or importer plug-in (read only) that adds the format (its id is plugin.; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…] (the importers' extensions that no built-in format reads included), unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}", always, formats ), diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 33ecfa8ab..49f8af812 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -651,11 +651,12 @@ pub fn read_extensions(f: &Format) -> &'static [&'static str] { READ_ALIASES.iter().find(|(id, _)| *id == f.id).map_or(f.extensions, |(_, exts)| *exts) } -/// The readable format of extension `ext` (any case, leading dot allowed): [`format`]'s when it -/// reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). +/// The built-in readable format of extension `ext` (any case, leading dot allowed): [`format`]'s +/// when it reads, else the one [`READ_ALIASES`] gives it (`.dib` is BMP). Importer plug-ins are +/// not looked at ([`importer_formats`]). pub fn readable_format(ext: &str) -> Option<&'static Format> { let k = ext.trim_start_matches('.').to_ascii_lowercase(); - format(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| format(id))) + builtin(&k).filter(|f| f.read).or_else(|| READ_ALIASES.iter().find(|(_, exts)| exts.contains(&k.as_str())).and_then(|(id, _)| builtin(id))) } /// The readable format a file name's extension names ([`readable_format`]). @@ -668,12 +669,38 @@ fn format_filters() -> impl Iterator &'static [&'static str] { + use vectorcraft_plugins::registry; + static MADE: std::sync::Mutex> = std::sync::Mutex::new(None); + let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); + let revision = registry::revision(); + if let Some((r, exts)) = *made + && r == revision + { + return exts; + } + let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec(); + for e in importer_formats().iter().flat_map(|f| f.extensions.iter().copied()) { + if !exts.contains(&e) && readable_format(e).is_none() { + exts.push(e); + } + } + let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) }; + *made = Some((revision, exts)); + exts +} + +/// Open-dialog filters: "All readable files" first, then one per readable format, then one per +/// importer plug-in (under its name), then swatch libraries (which open in the library panel), +/// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked +/// without running them) and plug-ins (installed). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", OPEN_EXTS)) + std::iter::once(("All readable files", open_extensions())) .chain(format_filters()) + .chain(importer_formats().into_iter().map(|f| (f.label, f.extensions))) .chain(std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS))) .chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS))) @@ -707,26 +734,40 @@ pub fn place_filters() -> impl Iterator Option<&'static Format> { let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase(); - FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| { + builtin(&k).or_else(|| { let added = plugin_formats(); - added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied() + let by_ext = |f: &&&Format| f.extensions.contains(&k.as_str()); + added + .iter() + .find(|f| f.id.eq_ignore_ascii_case(&k)) + .or_else(|| added.iter().filter(|f| f.write).find(by_ext)) + .or_else(|| added.iter().find(by_ext)) + .copied() }) } -/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in -/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for -/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues +/// A built-in format by id or extension (lower case, without the dot). +fn builtin(k: &str) -> Option<&'static Format> { + FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k))) +} + +/// The formats the installed exporter and importer plug-ins add ([`vectorcraft_plugins::export`], +/// [`vectorcraft_plugins::import`]), by plug-in id: an exporter's write only and an importer's read +/// only, with the plug-in's name as the label and its extensions. Each is made once for each +/// installation of its plug-in, its strings interned, so formats live as long as the catalogues /// that hold them. pub fn plugin_formats() -> Vec<&'static Format> { - use vectorcraft_plugins::{export, registry}; + use vectorcraft_plugins::{Kind, export, registry}; static MADE: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); - export::installed() + registry::list() .iter() + .filter(|p| matches!(p.manifest().kind, Kind::Exporter | Kind::Importer)) .map(|p| { if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) { return *f; @@ -738,8 +779,8 @@ pub fn plugin_formats() -> Vec<&'static Format> { label: registry::intern(&m.name), extensions: Box::leak(extensions.into_boxed_slice()), mime: "application/octet-stream", - read: false, - write: true, + read: m.kind == Kind::Importer, + write: m.kind == Kind::Exporter, raster: false, options: &[], })); @@ -749,6 +790,11 @@ pub fn plugin_formats() -> Vec<&'static Format> { .collect() } +/// The formats the installed importer plug-ins add ([`plugin_formats`] that read), by plug-in id. +pub fn importer_formats() -> Vec<&'static Format> { + plugin_formats().into_iter().filter(|f| f.read).collect() +} + /// The lower-case extension of a file name or path (empty when it has none). pub fn extension(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -837,7 +883,7 @@ pub(super) fn formats(_: &mut Session, _: &Value) -> Result { "formats": all().map(listed).collect::>(), "readable": ids(|f| f.read), "writable": ids(|f| f.write), - "openExtensions": OPEN_EXTS, + "openExtensions": open_extensions(), "unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::>(), })) } diff --git a/crates/engine/src/cmd/fileio/importer.rs b/crates/engine/src/cmd/fileio/importer.rs new file mode 100644 index 000000000..0a341c079 --- /dev/null +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -0,0 +1,115 @@ +//! `document.open` of the files importer plug-ins read ([`vectorcraft_plugins::import`]). +//! +//! A file opens with an importer when `importer` names one (its format id, `plugin.`), which +//! must list the file's extension, or when no built-in format reads the file, by its content or by +//! its extension, and an importer lists its extension (the one whose id sorts first, when several +//! do). A built-in format that can't read a damaged file reports its own error: the file is not +//! handed to an importer instead. Place reads only the built-in formats. +//! +//! The importer is given the file's bytes, at most [`import::MAX_INPUT_BYTES`] of them: a file +//! named by `path` that only an importer can read is read no further than that. Its objects become +//! a new document whose artboard is their bounds, and nothing opens when the importer fails, +//! declines, is cancelled or returns objects that don't pass the checks a filter's output does. + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::Document; +use vectorcraft_geom::Rect; +use vectorcraft_plugins::{Plugin, export, import, objects}; + +use super::super::{Result, bad}; +use super::load::{Loaded, detect, file_name}; +use super::{err, extension, file_stem, format}; +use crate::EngineError; + +const C: &str = "document.open"; + +/// The importer `document.open`'s params `p` name (`importer`), if any; a value that isn't a +/// string is an error. +fn named(p: &Value) -> Result> { + match p.get("importer").filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => v.as_str().map(Some).ok_or_else(|| bad(C, "`importer` must be a plug-in format id (see document.formats)")), + } +} + +/// The importer that opens the file `name` holding `bytes` with `document.open`'s params `p`: the +/// one `importer` names, which must list the file's extension; else, when no built-in format reads +/// the file, the first installed importer that lists its extension; else `None`. +pub(super) fn pick(name: &str, bytes: &[u8], p: &Value) -> Result>> { + let ext = extension(name); + if let Some(id) = named(p)? { + let plugin = import::named(id).map_err(|e| bad(C, e.to_string()))?; + let m = plugin.manifest(); + if !m.extensions.contains(&ext) { + let shown = vectorcraft_plugins::permissions::display_text(&m.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); + let exts: Vec = m.extensions.iter().map(|e| format!(".{e}")).collect(); + return Err(bad(C, format!("“{shown}” opens {} files, not `{}`", exts.join(", "), file_name(name)))); + } + return Ok(Some(plugin)); + } + if detect(name, bytes).is_some() { + return Ok(None); + } + Ok(import::for_extension(&ext).into_iter().next()) +} + +/// The file at `path` for `document.open`'s params `p`. A file that only an importer reads (one +/// `importer` names, or an extension no built-in format reads that an importer lists) is read up +/// to [`import::MAX_INPUT_BYTES`]: past that, it is an error, unless the start of the file shows a +/// built-in format and no importer was named, which is read whole as any other file is. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn read_path(path: &str, p: &Value) -> Result> { + let ext = extension(path); + let explicit = named(p)?.is_some(); + if !explicit && (super::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { + return super::read_file(path); + } + let head = super::read_file_head(path, import::MAX_INPUT_BYTES + 1)?; + if head.len() <= import::MAX_INPUT_BYTES { + return Ok(head); + } + if !explicit && detect(path, &head).is_some() { + return super::read_file(path); + } + Err(err(format!("can't open `{}`: {}", file_name(path), import::too_large()))) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn read_path(path: &str, _: &Value) -> Result> { + super::read_file(path) +} + +/// The file `name` holding `bytes` read by importer `plugin` with `document.open`'s params `p` +/// (`params`: the importer's own parameters) into a new document, its artboard the bounds of what +/// the importer returned. +pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Result { + let m = plugin.manifest(); + let format = format(&export::format_id(&m.id)).filter(|f| f.read).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(m.id.clone())))?; + let (ext, stem) = (extension(name), file_stem(name)); + let source = import::Source { extension: &ext, name: &stem }; + let params = p.get("params").cloned().unwrap_or(Value::Null); + let imported = import::run(plugin, bytes, ¶ms, source).map_err(|e| match e { + vectorcraft_plugins::Error::Cancelled => EngineError::Cancelled, + vectorcraft_plugins::Error::Params(why) => bad(C, why), + e => err(format!("can't open `{}`: {e}", file_name(name))), + })?; + let mut doc = Document::new(1.0, 1.0); + let layer = doc.layers.first().map(|l| l.id); + let admit = |fx: &[vectorcraft_doc::Effect], n: &vectorcraft_doc::Node| crate::cmd::plugin::admit_effects_for(C, fx, n); + let ids = + objects::apply_output(&mut doc, &[], imported.objects, layer, &admit).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; + if let (Some(bounds), Some(board)) = (doc.bounds_of(&ids, true), doc.artboards.first_mut()) { + board.rect = at_least_a_point(bounds); + } + doc.title = file_name(name); + Ok(Loaded { doc, format, warnings: imported.warnings, restored: false, converted: false, preview_only: false, editing_data: false }) +} + +/// `r` at least 1 pt wide and high, about its center: the artboard of a single line or point. +fn at_least_a_point(r: Rect) -> Rect { + let grow = |lo: f64, hi: f64| if hi - lo >= 1.0 { (lo, hi) } else { ((lo + hi) / 2.0 - 0.5, (lo + hi) / 2.0 + 0.5) }; + let ((x0, x1), (y0, y1)) = (grow(r.x0, r.x1), grow(r.y0, r.y1)); + Rect::new(x0, y0, x1, y1) +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index a1ec671aa..f32a456b1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -555,10 +555,15 @@ impl ReadFile { /// thread): read and decode the file, read its linked files and lay out its type → what /// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update -/// Links set to Automatically; see [`crate::cmd::links::resolve`]). +/// Links set to Automatically; see [`crate::cmd::links::resolve`]). A file an importer plug-in +/// reads (`importer` in `p`, or an extension only an importer lists) is read by it +/// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let mut loaded = load_with(name, bytes, &opts)?; + let mut loaded = match super::importer::pick(name, bytes, p)? { + Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, + None => load_with(name, bytes, &opts)?, + }; let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) @@ -663,7 +668,11 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result> { } pub(super) fn open(s: &mut Session, p: &Value) -> Result { - let src = source(p, "document.open")?; + // A file only an importer plug-in reads is read up to the importer's input limit. + let src = match str_param(p, "path") { + Some(path) => Source { name: path, bytes: super::importer::read_path(path, p)?, path: Some(path) }, + None => source(p, "document.open")?, + }; // A relative path is the file in the working directory: its folder is where relative links // are found (a bare file name has no folder of its own). let path = src.path.map(super::absolute_path); diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 565eded7f..fe6a562bf 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -7,7 +7,8 @@ //! plug-in that fails leaves the document untouched), and read and write the data they keep with //! the document ([`vectorcraft_doc::plugin_data`], theirs alone); live-effect plug-ins are applied //! like any effect (`effect.apply {effect: "plugin."}`) and drawn by `vectorcraft-effects`; -//! exporters add a file format that `document.export` writes (`fileio::plugin_formats`). +//! exporters add a file format that `document.export` writes, and importers one that +//! `document.open` reads (`fileio::plugin_formats`). //! //! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing, //! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), @@ -49,7 +50,7 @@ pub fn describe(p: &Plugin) -> Value { }); match m.kind { Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)), - Kind::Exporter => { + Kind::Exporter | Kind::Importer => { v["format"] = json!(export::format_id(&m.id)); v["extensions"] = json!(m.extensions); } @@ -128,6 +129,12 @@ impl FilterRun { format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)), )); } + Kind::Importer => { + return Err(bad( + C, + format!("`{}` is an importer: open a file with document.open {{\"importer\": \"{}\"}}", m.id, export::format_id(&m.id)), + )); + } } // Bad parameters fail before anything runs. let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); @@ -247,8 +254,13 @@ fn run(s: &mut Session, p: &Value) -> Result { /// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the /// host read one. fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, String> { + admit_effects_for("plugin.run", fx, n) +} + +/// [`admit_effects`] for command `cmd`, which bad parameters name: `plugin.run`, or `document.open` +/// for the objects an importer plug-in returns. +pub(crate) fn admit_effects_for(cmd: &str, fx: &[Effect], n: &Node) -> std::result::Result, String> { use vectorcraft_render::effects; - const C: &str = "plugin.run"; let mut out = Vec::with_capacity(fx.len()); for (j, e) in fx.iter().enumerate() { if n.appearance.effects.contains(e) { @@ -261,7 +273,7 @@ fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, St Some(plugin) => { plugin.manifest().resolve_params(¶ms).map_err(|why| refuse(why.to_string()))?; } - None => super::params::check_effect_params(C, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?, + None => super::params::check_effect_params(cmd, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?, } if super::effectcmd::texture_load_effect(&e.id) && let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE) @@ -493,7 +505,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\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, 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}", always, list ), diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1f29627e0..2a4a5f991 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -242,6 +242,24 @@ proptest! { renders(&s, &out)?; } + /// An importer's hostile output (`document.open`) either opens a document that renders and + /// exports, or is an error that opens nothing. + #[test] + fn hostile_importer_output_never_crashes( + objects in prop::collection::vec(arb_object(), 0..5), + junk in prop::option::weighted(0.1, arb_junk()), + ) { + let mut s = Session::new(); + let out = output(&objects, &[], junk); + install(&mut s, &wat::parse_str(tpl::returning(&tpl::importer("fuzz.importer", r#"["fzi"]"#, "{}"), &out)).unwrap()).unwrap(); + let r = catch_quiet(|| s.execute("document.open", &json!({"name": "x.fzi", "dataBase64": ""})).map(|_| ())); + match r { + Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))), + Ok(Err(_)) => prop_assert!(s.documents().is_empty(), "a failed open opened a document: {}", out), + Ok(Ok(())) => renders(&s, &out)?, + } + } + #[test] fn mutated_and_garbage_modules_never_crash( flips in prop::collection::vec((any::(), any::()), 0..8), @@ -276,10 +294,10 @@ proptest! { /// character from the name. #[test] fn hostile_manifest_permissions_never_crash( - kind in prop::sample::select(vec!["filter", "effect", "exporter"]), + kind in prop::sample::select(vec!["filter", "effect", "exporter", "importer"]), names in prop::collection::vec( prop_oneof![ - prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "progress", "ui.panel", ""]), + prop::sample::select(vec!["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress", "ui.panel", ""]), Just("doc.read\u{202E}"), Just("\u{0007}"), ], @@ -288,10 +306,10 @@ proptest! { permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), api in prop_oneof![3 => Just(json!(1)), 1 => arb_junk()], name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"], - grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]))], + grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read"]))], ) { let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); - if kind == "exporter" { + if matches!(kind, "exporter" | "importer") { m["extensions"] = json!(["fz"]); } m["permissions"] = match permissions { diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs new file mode 100644 index 000000000..f56684172 --- /dev/null +++ b/crates/engine/tests/plugin_import.rs @@ -0,0 +1,252 @@ +//! Importer plug-ins (`"kind": "importer"`, docs/plugins.md › Importers) through the engine: the +//! formats they add to `document.formats` and `document.open`, which files they are given, the +//! document their objects become, and what happens when they fail, decline or are cancelled. +//! +//! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the +//! exact list of formats would see these ones. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; +use vectorcraft_engine::{EngineError, Session}; +use vectorcraft_plugins::wat::{self as tpl, Given}; + +/// An importer's output: one red rectangle from (10, 20) to (110, 70) and a warning. +const RECT: &str = r#"{"objects":[{"type":"path","name":"Box","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]},"fills":[{"type":"solid","color":{"model":"rgb","r":1,"g":0,"b":0}}]}],"warnings":["2 layers merged"]}"#; + +/// An importer's output: a horizontal line from (0, 5) to (50, 5). +const LINE: &str = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,5]},{"p":[50,5]}]}]}}]}"#; + +/// Installs the plug-in written in WAT `src` → its `plugin.list` entry. +fn install(s: &mut Session, src: &str) -> Value { + let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}")); + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap() +} + +/// An importer `id` reading `extensions` (a JSON list) whose objects are the file it is given. +fn echo(s: &mut Session, id: &str, extensions: &str) -> Value { + install(s, &tpl::module(&tpl::importer(id, extensions, "{}"), tpl::ECHO, "", 1)) +} + +/// `document.open` of a file `name` holding `bytes`, with more params `extra`. +fn open(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> Result { + let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + s.execute("document.open", &p) +} + +/// The active document's first artboard, as `(x0, y0, x1, y1)`. +fn artboard(s: &Session) -> (f64, f64, f64, f64) { + let r = s.doc().unwrap().doc.artboards[0].rect; + (r.x0, r.y0, r.x1, r.y1) +} + +/// How many objects the active document's first layer holds. +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers[0].children().unwrap().len() +} + +/// A fresh folder for files this test writes. +fn tmp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-plugin-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn path_of(p: &std::path::Path) -> String { + p.to_string_lossy().into_owned() +} + +#[test] +fn an_importer_adds_a_read_only_format_while_it_is_installed() { + let mut s = Session::new(); + let entry = echo(&mut s, "test.list", r#"["tsl","tsl2"]"#); + assert_eq!((&entry["kind"], &entry["format"], &entry["extensions"]), (&json!("importer"), &json!("plugin.test.list"), &json!(["tsl", "tsl2"]))); + assert_eq!(entry["permissions"], json!(["import.read"])); + let formats = s.execute("document.formats", &json!({})).unwrap(); + let listed = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "plugin.test.list").expect("listed"); + assert_eq!((&listed["label"], &listed["extensions"]), (&json!("Test test.list"), &json!(["tsl", "tsl2"]))); + assert_eq!((&listed["read"], &listed["write"], &listed["plugin"]), (&json!(true), &json!(false), &json!("test.list"))); + assert!(formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(!formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(formats["openExtensions"].as_array().unwrap().contains(&json!("tsl2"))); + assert!(fileio::open_extensions().contains(&"tsl")); + assert!(!fileio::export_extensions().contains(&"tsl")); + assert_eq!(fileio::format(".TSL2").map(|f| f.id), Some("plugin.test.list"), "by any of its extensions, in any case"); + + s.execute("plugin.remove", &json!({"id": "test.list"})).unwrap(); + let formats = s.execute("document.formats", &json!({})).unwrap(); + assert!(!formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list"))); + assert!(!formats["openExtensions"].as_array().unwrap().contains(&json!("tsl"))); + assert!(fileio::format("tsl").is_none()); + assert!(open(&mut s, "a.tsl", RECT.as_bytes(), json!({})).is_err()); + assert!(s.documents().is_empty()); +} + +/// A file of an extension only the importer lists opens as a new document of editable art, its +/// artboard the art's bounds, with the importer's warnings; Save then asks for a name. +#[test] +fn a_file_an_importer_reads_opens_as_editable_art() { + let dir = tmp_dir("open"); + let mut s = Session::new(); + echo(&mut s, "test.open", r#"["tso"]"#); + let r = open(&mut s, "Shapes.tso", RECT.as_bytes(), json!({})).unwrap(); + assert_eq!((&r["format"], &r["warnings"], &r["restored"]), (&json!("plugin.test.open"), &json!(["2 layers merged"]), &json!(false))); + assert_eq!(s.documents().len(), 1); + assert_eq!(objects(&s), 1); + assert_eq!(artboard(&s), (10.0, 20.0, 110.0, 70.0)); + // Editable: the rectangle moves like any path. + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + + // A line's artboard is at least 1 pt high. + open(&mut s, "Line.tso", LINE.as_bytes(), json!({})).unwrap(); + assert_eq!(artboard(&s), (0.0, 4.5, 50.0, 5.5)); + + // From a path: the document keeps no path, so Save asks for a name. + let path = dir.join("Shapes.tso"); + std::fs::write(&path, RECT).unwrap(); + let r = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap(); + assert_eq!(r["format"], "plugin.test.open"); + assert_eq!(s.doc().unwrap().path, None); + assert!(s.doc().unwrap().doc.title.contains("Shapes"), "{}", s.doc().unwrap().doc.title); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A built-in format keeps its files: an importer that lists `svg` is given an `.svg` only when +/// `importer` names it, and a file with a built-in format's content opens with the built-in reader +/// whatever its extension. +#[test] +fn built_in_formats_keep_their_files() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.svgtoo", r#"["tsv","svg"]"#, "{}"), RECT)); + let svg = br#""#; + assert_eq!(open(&mut s, "a.svg", svg, json!({})).unwrap()["format"], "svg"); + // A damaged SVG reports the SVG reader's error; the importer isn't tried instead. + match open(&mut s, "b.svg", b" assert_eq!(r["format"], "svg"), + Err(e) => assert!(!e.to_string().contains("importer"), "{e}"), + } + // Named, the importer opens an .svg. + let r = open(&mut s, "c.svg", svg, json!({"importer": "plugin.test.svgtoo"})).unwrap(); + assert_eq!(r["format"], "plugin.test.svgtoo"); + // An SVG named as the importer's file is still an SVG. + assert_eq!(open(&mut s, "d.tsv", svg, json!({})).unwrap()["format"], "svg"); + assert_eq!(fileio::format("svg").map(|f| f.id), Some("svg")); + assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1); +} + +/// Several importers list one extension: the one whose id sorts first opens the file, and +/// `importer` picks another (by its format id or its id). +#[test] +fn the_first_importer_by_id_opens_a_shared_extension() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.shared.b", r#"["tsh"]"#, "{}"), LINE)); + install(&mut s, &tpl::returning(&tpl::importer("test.shared.a", r#"["tsh"]"#, "{}"), RECT)); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({})).unwrap()["format"], "plugin.test.shared.a"); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "plugin.test.shared.b"})).unwrap()["format"], "plugin.test.shared.b"); + assert_eq!(open(&mut s, "x.tsh", b"?", json!({"importer": "test.shared.b"})).unwrap()["format"], "plugin.test.shared.b"); +} + +#[test] +fn importer_names_that_do_not_fit_are_errors() { + let mut s = Session::new(); + echo(&mut s, "test.names", r#"["tsn"]"#); + install(&mut s, &tpl::returning(&tpl::manifest("test.names.filter", "filter", "{}"), r#"{"objects":[]}"#)); + let cases = [ + (json!({"importer": "plugin.test.names"}), "x.png", "opens .tsn files, not `x.png`"), + (json!({"importer": "plugin.test.names.filter"}), "x.tsn", "is a filter, not an importer"), + (json!({"importer": "plugin.test.nothere"}), "x.tsn", "test.nothere"), + (json!({"importer": 3}), "x.tsn", "`importer` must be a plug-in format id"), + ]; + for (extra, name, why) in cases { + let e = open(&mut s, name, RECT.as_bytes(), extra.clone()).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{extra}: {e}"); + assert!(e.to_string().contains(why), "{extra}: {e}"); + } + assert!(s.documents().is_empty()); + // Without an importer, a file nothing reads stays unsupported. + assert!(open(&mut s, "x.tsunknown", RECT.as_bytes(), json!({})).is_err()); + // An importer is not a filter. + s.execute("file.new", &json!({})).unwrap(); + let e = s.execute("plugin.run", &json!({"id": "test.names"})).unwrap_err(); + assert!(e.to_string().contains("document.open"), "{e}"); +} + +/// The importer is given its parameters, checked against its manifest, and `_context` with the +/// file's extension and name. +#[test] +fn the_importer_is_given_its_parameters_and_the_file_s_name() { + let mut s = Session::new(); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + let needles = [r#""mode":"import""#, r#""extension":"tsc""#, r#""name":"Shapes""#, r#""density":10.0"#]; + for needle in needles { + // RECT when the parameters hold the needle; the file, which isn't JSON, otherwise. + install(&mut s, &tpl::when_given(&tpl::importer("test.params", r#"["tsc"]"#, params), Given::Params, needle, RECT)); + let r = open(&mut s, "Shapes.tsc", b"not json", json!({"params": {"density": 12}})); + assert_eq!(r.map(|r| r["format"].clone()).map_err(|e| e.to_string()), Ok(json!("plugin.test.params")), "{needle}"); + } + let before = s.documents().len(); + let e = open(&mut s, "Shapes.tsc", b"", json!({"params": {"density": "lots"}})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{e}"); + assert_eq!(s.documents().len(), before); +} + +#[test] +fn failures_open_nothing() { + let mut s = Session::new(); + let far = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[9000000,0]}]}]}}]}"#; + let bad_warnings = LINE.replace("]}]}}]}", r#"]}]}}],"warnings":"no"}"#); + let cases = [ + ("test.fail.declines", r#"{"error":"Not a drawing."}"#, "Not a drawing."), + ("test.fail.notjson", "not json", "JSON"), + ("test.fail.empty", r#"{"objects":[]}"#, "the importer returned no objects"), + ("test.fail.key", r#"{"objects":[],"layers":[]}"#, "an importer returns `objects` and `warnings`, not `layers`"), + ("test.fail.id", r#"{"objects":[{"id":3}]}"#, "is not the id of an input object"), + ("test.fail.far", far, "4000000"), + ("test.fail.warnings", bad_warnings.as_str(), "`warnings` must be a list of strings"), + ]; + for (id, output, why) in cases { + install(&mut s, &tpl::returning(&tpl::importer(id, r#"["tsf"]"#, "{}"), output)); + let e = open(&mut s, "x.tsf", b"?", json!({"importer": format!("plugin.{id}")})).unwrap_err(); + assert!(e.to_string().contains(why), "{id}: {e}"); + assert!(e.to_string().contains("x.tsf"), "{id}: names the file: {e}"); + } + install(&mut s, &tpl::module(&tpl::importer("test.fail.traps", r#"["tsf"]"#, "{}"), "(unreachable)", "", 1)); + assert!(open(&mut s, "x.tsf", b"?", json!({"importer": "plugin.test.fail.traps"})).is_err()); + assert!(s.documents().is_empty(), "nothing opened"); +} + +/// Cancel stops an importer between two slices of its budget, and nothing opens: here one that +/// never returns, run with a token cancelled beforehand. +#[test] +fn a_cancelled_open_stops_the_importer() { + let mut s = Session::new(); + install(&mut s, &tpl::module(&tpl::importer("test.spins", r#"["tss"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let r = vectorcraft_doc::cancel::scope(&token, None, || open(&mut s, "x.tss", b"?", json!({}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert!(s.documents().is_empty()); +} + +/// A file past the importer's input limit is not read past it, and doesn't open. +#[test] +fn a_file_past_the_input_limit_is_refused() { + let dir = tmp_dir("large"); + let mut s = Session::new(); + echo(&mut s, "test.large", r#"["tsb"]"#); + let path = dir.join("big.tsb"); + std::fs::File::create(&path).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let e = s.execute("document.open", &json!({"path": path_of(&path)})).unwrap_err(); + assert!(e.to_string().contains("larger than"), "{e}"); + assert!(e.to_string().contains("big.tsb"), "{e}"); + assert!(s.documents().is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/plugins/src/import.rs b/crates/plugins/src/import.rs new file mode 100644 index 000000000..5a7be6a85 --- /dev/null +++ b/crates/plugins/src/import.rs @@ -0,0 +1,182 @@ +//! Importer plug-ins: a plug-in of kind `importer` reads files in a format of its own, such as a +//! pen plotter's or a cutting machine's, into a new document. +//! +//! File › Open opens a file with an importer only when no built-in format reads it: the built-in +//! formats are found by the file's content first and then by its extension, and an importer is +//! picked by an extension it lists. When several importers list the extension, the one whose id +//! sorts first opens it ([`for_extension`]). File › Open With and `document.open {importer}` pick an +//! importer by its format id (`plugin.`), also for an extension a built-in format reads. +//! +//! The host hands the importer the file's bytes as they are, at most [`MAX_INPUT_BYTES`] of them, +//! and reads back the objects of the new document in the same form filters return them +//! ([`crate::objects`]): `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline. +//! An import is one run the user asked for, so it gets an export's budget ([`crate::export::FUEL`] +//! and [`crate::export::WALL_TIME_MS`]), and Cancel stops it between two slices of the budget +//! ([`Error::Cancelled`]). + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::objects::{OutObject, declined, printable}; +use crate::{Error, Kind, Limits, Plugin, Result, registry}; + +/// The largest file an importer is given, in bytes: 128 MiB, a quarter of a plug-in's memory +/// limit, so the file and the objects made from it fit beside each other. +pub const MAX_INPUT_BYTES: usize = 128 << 20; + +/// The keys an importer's output may have. +const OUTPUT_KEYS: &[&str] = &["objects", "warnings", "error"]; + +/// The installed importers, by plug-in id. +pub fn installed() -> Vec> { + registry::list().into_iter().filter(|p| p.manifest().kind == Kind::Importer).collect() +} + +/// The installed importer of format id `format_id` (`plugin.`). +pub fn by_format(format_id: &str) -> Option> { + registry::get(crate::export::plugin_id(format_id)?).filter(|p| p.manifest().kind == Kind::Importer) +} + +/// The installed importer `importer` names: its format id (`plugin.`) or its plug-in id. A +/// plug-in of another kind, and one that isn't installed, are errors that say so. +pub fn named(importer: &str) -> Result> { + let id = crate::export::plugin_id(importer).unwrap_or(importer); + let plugin = registry::get(id).ok_or_else(|| Error::NotFound(id.into()))?; + match plugin.manifest().kind { + Kind::Importer => Ok(plugin), + _ => Err(not_importer(plugin.manifest())), + } +} + +/// The error of plug-in `m`, which is not an importer, asked to import. +fn not_importer(m: &crate::Manifest) -> Error { + Error::Params(format!("`{}` is a {}, not an importer", m.id, m.kind.name())) +} + +/// The installed importers that list extension `ext` (any case, leading dot allowed), by plug-in +/// id: the first is the one File › Open uses. +pub fn for_extension(ext: &str) -> Vec> { + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + installed().into_iter().filter(|p| p.manifest().extensions.contains(&ext)).collect() +} + +/// What an importer returned. +#[derive(Clone, Debug, PartialEq)] +pub struct Imported { + /// The objects of the new document, in paint order (bottom first), checked as a filter's are. + pub objects: Vec, + /// What the import left out or changed, for the user (at most + /// [`crate::export::MAX_WARNINGS`]). + pub warnings: Vec, +} + +/// The file an import reads, as the importer is told in `_context`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Source<'a> { + /// Its extension, without the dot: one the importer lists. + pub extension: &'a str, + /// Its name without the folder and extension. + pub name: &'a str, +} + +/// The error of a file larger than [`MAX_INPUT_BYTES`], which no importer is given. +pub fn too_large() -> Error { + Error::Io(format!("the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads")) +} + +/// The limits of an import by `plugin`: its own, with the budgets of an export. +pub fn limits(plugin: &Plugin) -> Limits { + crate::export::limits(plugin) +} + +/// Runs importer `plugin` on `file`, the bytes of the file being opened, with `params` (checked +/// against its manifest: missing ones take their defaults) for the file `source`. +pub fn run(plugin: &Plugin, file: &[u8], params: &Value, source: Source) -> Result { + let m = plugin.manifest(); + if m.kind != Kind::Importer { + return Err(not_importer(m)); + } + if file.len() > MAX_INPUT_BYTES { + return Err(too_large()); + } + let mut params = Value::Object(m.resolve_params(params)?); + params["_context"] = json!({"mode": "import", "extension": source.extension, "name": source.name}); + let params = serde_json::to_vec(¶ms).map_err(|e| Error::Params(e.to_string()))?; + decode(&plugin.run_limited(file, ¶ms, &limits(plugin))?) +} + +/// An importer's output: `{"objects": [...], "warnings"?: ["…"]}`, or `{"error": "…"}` to decline +/// with a message for the user. The objects are new ones (no `id`), checked as a filter's are; an +/// output without any, or with keys of its own, is refused. +pub fn decode(bytes: &[u8]) -> Result { + let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; + if let Some(e) = declined(&v) { + return Err(e); + } + if let Some(key) = v.as_object().and_then(|o| o.keys().find(|k| !OUTPUT_KEYS.contains(&k.as_str()))) { + let key = printable(&json!(key), 64); + return Err(Error::Output(format!("an importer returns `objects` and `warnings`, not `{key}`"))); + } + let objects = crate::objects::decode(bytes)?; + if objects.is_empty() { + return Err(Error::Output("the importer returned no objects".into())); + } + let warnings = match v.get("warnings") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(w)) => w.iter().take(crate::export::MAX_WARNINGS).map(|w| printable(w, crate::export::MAX_WARNING_CHARS)).collect(), + Some(_) => return Err(Error::Output("`warnings` must be a list of strings".into())), + }; + Ok(Imported { objects, warnings }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SQUARE: &str = r#"{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]}]}]}}"#; + + #[test] + fn output_is_the_objects_and_the_warnings_or_a_decline() { + let out = decode(format!(r#"{{"objects":[{SQUARE}],"warnings":["a",2]}}"#).as_bytes()).unwrap(); + assert_eq!((out.objects.len(), out.warnings), (1, vec!["a".to_string(), "2".to_string()])); + assert!(out.objects.iter().all(|o| o.id.is_none() && o.path.is_some())); + assert_eq!(decode(format!(r#"{{"objects":[{SQUARE}]}}"#).as_bytes()).unwrap().warnings, Vec::::new()); + assert_eq!(decode(br#"{"error": "Not a\nplot file."}"#), Err(Error::Failed("Not aplot file.".into()))); + } + + /// Nothing an importer returns reaches a document unchecked: malformed JSON, no objects, keys + /// the host doesn't read, bad warnings and bad objects (coordinates out of range, unknown paints) + /// are all errors. + #[test] + fn bad_output_is_refused() { + let far = r#"{"objects":[{"path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#; + for (bad, says) in [ + (&b"[]"[..], "objects"), + (b"{}", "objects"), + (b"not json", "not JSON"), + (br#"{"objects":[]}"#, "no objects"), + (br#"{"objects":{}}"#, "objects"), + (br#"{"objects":[1]}"#, "JSON object"), + (far.as_bytes(), "out of range"), + (br#"{"objects":[{"path":{"subpaths":[]},"fills":[{"type":"hatch"}]}]}"#, "objects[0]"), + (br#"{"objects":[],"artboard":[0,0,1,1]}"#, "not `artboard`"), + ] { + let e = decode(bad).unwrap_err().to_string(); + assert!(e.contains(says), "{}: {e}", String::from_utf8_lossy(bad)); + } + let warns = format!(r#"{{"objects":[{SQUARE}],"warnings":"x"}}"#); + assert!(decode(warns.as_bytes()).unwrap_err().to_string().contains("`warnings`")); + } + + #[test] + fn warnings_are_capped_in_number_and_length() { + let many: Vec = (0..crate::export::MAX_WARNINGS + 5).map(|i| format!("w{i}")).collect(); + let square: Value = serde_json::from_str(SQUARE).unwrap(); + let out = decode(json!({"objects": [square], "warnings": many}).to_string().as_bytes()).unwrap(); + assert_eq!(out.warnings.len(), crate::export::MAX_WARNINGS); + let long = "x".repeat(crate::export::MAX_WARNING_CHARS + 1); + let out = decode(json!({"objects": [square], "warnings": [long]}).to_string().as_bytes()).unwrap(); + assert_eq!(out.warnings.first().map(|w| w.chars().count()), Some(crate::export::MAX_WARNING_CHARS)); + } +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 9e6f9e934..47037c4db 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -19,7 +19,8 @@ //! 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 -//! document in a file format of its own. The process-wide [`registry`] holds the installed +//! document in a file format of its own; an **importer** ([`import`]) reads a file in a format of +//! its own into a new document. 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 //! the set of [`permissions`] its manifest lists, which the user approves when it is installed. @@ -28,6 +29,7 @@ pub mod dialog; pub mod effect; pub mod export; +pub mod import; pub mod manifest; pub mod objects; pub mod permissions; @@ -40,7 +42,7 @@ pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect -/// (`plugin.`, [`effect`]) or its file format ([`export`]). +/// (`plugin.`, [`effect`]) or its file format ([`export`], [`import`]). pub const PREFIX: &str = "plugin."; /// A plug-in error. Every way a module can misbehave ends here, never in a panic. diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 75a76cdbb..de4013869 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -18,7 +18,7 @@ pub const MAX_UNGROUPED: usize = 64; pub const MAX_TEXT: usize = 4_096; /// Most numbers a `numberList` parameter may hold. pub const MAX_LIST: usize = 256; -/// Most file extensions an exporter may declare. +/// Most file extensions an exporter or an importer may declare. pub const MAX_EXTENSIONS: usize = 8; /// What a plug-in is. @@ -34,6 +34,9 @@ pub enum Kind { /// An exporter: writes the document in a file format of its own, whose extensions the manifest /// lists (File › Export › Export As, `document.export`; see [`crate::export`]). Exporter, + /// An importer: reads a file in a format of its own, whose extensions the manifest lists, into + /// a new document (File › Open, `document.open`; see [`crate::import`]). + Importer, } impl Kind { @@ -42,6 +45,7 @@ impl Kind { Kind::Filter => "filter", Kind::Effect => "effect", Kind::Exporter => "exporter", + Kind::Importer => "importer", } } } @@ -253,8 +257,8 @@ pub struct Manifest { /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] pub params: Vec<(String, Param)>, - /// An exporter's file extensions, lower case and without the dot; the first is the one its - /// files get. Only exporters declare them. + /// An exporter's or an importer's file extensions, lower case and without the dot; the first is + /// the one an exporter's files get. Only exporters and importers declare them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extensions: Vec, /// How much a run may compute: only a filter may be [`Budget::Heavy`]. @@ -319,6 +323,9 @@ impl Manifest { (Budget::Heavy, Kind::Exporter) => { return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into())); } + (Budget::Heavy, Kind::Importer) => { + return Err(Error::Manifest("an importer always gets an import's budget: leave `budget` out".into())); + } } for (i, (k, p)) in self.params.iter().enumerate() { if !valid_key(k) { @@ -333,17 +340,17 @@ impl Manifest { Ok(()) } - /// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case - /// letters and digits; other kinds list none. + /// An exporter or an importer lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 + /// lower-case letters and digits; other kinds list none. fn validate_extensions(&self) -> Result<()> { let exts = &self.extensions; + let count_ok = !exts.is_empty() && exts.len() <= MAX_EXTENSIONS; match self.kind { - Kind::Exporter => {} + Kind::Exporter if !count_ok => return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))), + Kind::Importer if !count_ok => return Err(Error::Manifest(format!("an importer declares 1 to {MAX_EXTENSIONS} `extensions`"))), + Kind::Exporter | Kind::Importer => {} _ if exts.is_empty() => return Ok(()), - _ => return Err(Error::Manifest("only an exporter declares `extensions`".into())), - } - if exts.is_empty() || exts.len() > MAX_EXTENSIONS { - return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`"))); + _ => return Err(Error::Manifest("only an exporter or an importer declares `extensions`".into())), } for (i, e) in exts.iter().enumerate() { if !(1..=16).contains(&e.len()) || !e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) { @@ -608,6 +615,31 @@ mod tests { } } + /// An importer lists the extensions it reads by the exporter's rules, and gets an import's + /// budget and the `import.read` permission. + #[test] + fn an_importer_lists_its_extensions() { + let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"importer","extensions":["plt","hpgl"]}"#).unwrap(); + assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Importer, &["plt".to_string(), "hpgl".to_string()][..])); + assert_eq!(serde_json::to_value(&m).unwrap()["kind"], json!("importer")); + assert_eq!(m.permissions(), [Permission::ImportRead]); + for (bad, says) in [ + (r#"{"id":"a","name":"x","kind":"importer"}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":[]}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["a","b","c","d","e","f","g","h","i"]}"#, "an importer declares 1 to 8"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["Plt"]}"#, "lower-case"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":[".plt"]}"#, "without the dot"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["abcdefghijklmnopq"]}"#, "1-16"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt","plt"]}"#, "listed twice"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"budget":"heavy"}"#, "an import's budget"), + (r#"{"id":"a","name":"x","kind":"importer","extensions":["plt"],"permissions":["doc.read"]}"#, "doesn't apply"), + (r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#, "only an exporter or an importer"), + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains(says), "{bad}: {e}"); + } + } + /// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an /// exporter has an export's budget, so they can't. Light, the default, isn't written back. #[test] diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 1d6b0664e..540a091a3 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -47,10 +47,14 @@ pub enum Permission { /// Write the document as a file in its own format (an exporter). #[serde(rename = "export.write")] ExportWrite, + /// Read a file the user opens and make a new document from it (an importer). + #[serde(rename = "import.read")] + ImportRead, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 5] = [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite]; +pub const ALL: [Permission; 6] = + [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::ImportRead]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -61,6 +65,7 @@ impl Permission { Permission::State => "state", Permission::EffectRender => "effect.render", Permission::ExportWrite => "export.write", + Permission::ImportRead => "import.read", } } @@ -77,6 +82,7 @@ impl Permission { Permission::State => "Keep data of its own with the document", Permission::EffectRender => "Change how the objects its effect is applied to are drawn", Permission::ExportWrite => "Write the document as a file in its own format", + Permission::ImportRead => "Read files you open in its format and make documents from them", } } @@ -87,16 +93,17 @@ impl Permission { Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], Kind::Effect => &[Permission::DocRead, Permission::EffectRender], Kind::Exporter => &[Permission::DocRead, Permission::ExportWrite], + Kind::Importer => &[Permission::ImportRead], } } /// The permissions a plug-in of `kind` can't work without, which its list must hold: a live - /// effect and an exporter are always handed objects, and only draw or write. A filter may - /// leave any of its permissions out. + /// effect and an exporter are always handed objects, and only draw or write; an importer is + /// always handed the file being opened. A filter may leave any of its permissions out. fn required(kind: Kind) -> &'static [Permission] { match kind { Kind::Filter => &[], - Kind::Effect | Kind::Exporter => Permission::implied(kind), + Kind::Effect | Kind::Exporter | Kind::Importer => Permission::implied(kind), } } } @@ -218,7 +225,11 @@ mod tests { assert_eq!(validate(Kind::Filter, &[]).unwrap(), []); assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Importer, &list(&["import.read"])).unwrap(), [Permission::ImportRead]); for (kind, names, says) in [ + (Kind::Importer, &["import.read", "doc.read"][..], "`doc.read` doesn't apply to a plug-in of kind `importer`"), + (Kind::Importer, &[], "needs the permission `import.read`"), + (Kind::Exporter, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"), (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), (Kind::Filter, &["progress"], "`progress` is reserved"), (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 0201e1a50..13570d36f 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -117,6 +117,11 @@ pub fn exporter(id: &str, extensions: &str, params: &str) -> String { format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#) } +/// The manifest of a test importer `id` reading `extensions` (a JSON list) with `params`. +pub fn importer(id: &str, extensions: &str, params: &str) -> String { + format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"importer","extensions":{extensions},"params":{params}}}"#) +} + /// What [`echo_exporter`] writes as its file. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Echo { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ed9f909b3..c98a23887 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -803,6 +803,8 @@ mod tests_plugin_export; #[cfg(test)] mod tests_plugin_fields; #[cfg(test)] +mod tests_plugin_import; +#[cfg(test)] mod tests_raster_formats; #[cfg(test)] mod tests_svg_options_range; diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index eef4787ee..8096f1e58 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -8,7 +8,8 @@ //! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: //! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its -//! Export button writes the file. +//! Export button writes the file. Importers open the files File › Open finds no built-in format +//! for, and File › Open With lists them, to open a file with one of them by name. use std::sync::Arc; @@ -56,6 +57,18 @@ pub fn object_menu() -> Vec { items } +/// File › Open With's header while no importer plug-in is installed. +pub const NO_IMPORTERS: &str = "No importer plug-ins installed"; + +/// File › Open With: the installed importers (`file.openWith {importer}`), each by its name. +pub fn open_with_menu() -> Vec { + let items: Vec = vectorcraft_plugins::import::installed() + .iter() + .map(|p| Item::Cmd(label(&p.manifest().name, true), "file.openWith", json!({ "importer": vectorcraft_plugins::export::format_id(p.id()) }))) + .collect(); + if items.is_empty() { vec![Item::Header(NO_IMPORTERS)] } else { items } +} + /// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without /// options apply at once, as built-in ones do. pub fn effect_menu() -> Option { @@ -79,6 +92,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Kind::Filter => {} Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)), Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", m.name)), + Kind::Importer => return Err(format!("{} is an importer (File › Open With)", m.name)), } let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); if m.params.is_empty() || given.is_some() { diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs new file mode 100644 index 000000000..7d2e7b9a1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -0,0 +1,103 @@ +//! Importer plug-ins in File › Open With and File › Open: listed by name under Open With, which +//! opens a file with the importer also when a built-in format reads its extension, and a file only +//! an importer reads is not read past the importer's input limit. Each test installs importers of +//! its own and removes them: plug-ins are process-wide, and an importer left installed would show +//! in other tests' Open dialog. + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +use super::*; +use crate::Services; +use crate::menus::Item; + +/// An importer's output: one rectangle from (10, 20) to (110, 70). +const RECT: &str = + r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; + +/// An app that reads files from disk, refusing to read one past the importers' input limit. +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.services.read = Some(Box::new(|p: &str| match std::fs::metadata(p) { + Ok(m) if m.len() > vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 => Err("read past the limit".into()), + _ => std::fs::read(p).map_err(|e| e.to_string()), + })); + app +} + +fn install(app: &mut VectorcraftApp, src: &str) { + let wasm = wat::parse_str(src).unwrap(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); +} + +/// A fresh folder for files this test writes. +fn tmp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-ui-plugin-import-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The active document's first artboard, as `(x0, y0, x1, y1)`. +fn artboard(app: &VectorcraftApp) -> (f64, f64, f64, f64) { + let r = app.session.doc().unwrap().doc.artboards[0].rect; + (r.x0, r.y0, r.x1, r.y1) +} + +/// Open With lists an importer by name; it opens an `.svg` with the importer, where File › Open +/// keeps the built-in SVG reader. +#[test] +fn open_with_lists_importers_and_opens_a_file_with_one() { + let dir = tmp_dir("with"); + let mut app = app(); + install(&mut app, &tpl::returning(&tpl::importer("test.ui.svgreader", r#"["svg","tui"]"#, "{}"), RECT)); + let menu = plugin::open_with_menu(); + let listed = menu.iter().any(|i| match i { + Item::Cmd(label, "file.openWith", p) => *label == "Test test.ui.svgreader…" && p["importer"] == "plugin.test.ui.svgreader", + _ => false, + }); + assert!(listed, "{menu:?}"); + + let svg = dir.join("a.svg"); + std::fs::write(&svg, r#""#).unwrap(); + let svg = svg.to_string_lossy().into_owned(); + crate::io::open_path(&mut app, &svg).unwrap(); + assert_eq!(artboard(&app), (0.0, 0.0, 50.0, 40.0), "File › Open: the built-in SVG reader"); + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.svgreader", "path": svg})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.svgreader"); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer"); + assert_eq!(app.session.documents().len(), 2); + + // An importer is not a filter: Object › Plug-ins' command says where it is. + let e = app.run("plugin.dialog", json!({"id": "test.ui.svgreader"})).unwrap_err(); + assert!(e.contains("is an importer (File › Open With)"), "{e}"); + let e = app.run("file.openWith", json!({"path": svg})).unwrap_err(); + assert!(e.contains("missing `importer`"), "{e}"); + app.run("plugin.remove", json!({"id": "test.ui.svgreader"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File › Open reads a file of an extension only an importer lists, and refuses one past the +/// importer's input limit before reading it, as Open With does. +#[test] +fn file_open_refuses_an_importer_s_file_past_the_limit_before_reading_it() { + let dir = tmp_dir("limit"); + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.big", r#"["tub"]"#, "{}"), tpl::ECHO, "", 1)); + let small = dir.join("small.tub"); + std::fs::write(&small, RECT).unwrap(); + crate::io::open_path(&mut app, &small.to_string_lossy()).unwrap(); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0)); + + let big = dir.join("big.tub"); + std::fs::File::create(&big).unwrap().set_len(vectorcraft_plugins::import::MAX_INPUT_BYTES as u64 + 1).unwrap(); + let big = big.to_string_lossy().into_owned(); + let e = crate::io::open_path(&mut app, &big).unwrap_err(); + assert!(e.contains("larger than"), "{e}"); + let e = app.run("file.openWith", json!({"importer": "plugin.test.ui.big", "path": big})).unwrap_err(); + assert!(e.contains("larger than"), "{e}"); + assert_eq!(app.session.documents().len(), 1); + app.run("plugin.remove", json!({"id": "test.ui.big"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 19d428340..925865744 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6282,3 +6282,8 @@ breakText Glyphs Глифи # 3D Box add-on 3D Box 3D кутия 3D Box Tool Инструмент за 3D кутия + +# Importer plug-ins (File › Open With, document.open) + Open With Отваряне с + No importer plug-ins installed Няма инсталирани плъгини за импортиране + Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 50553d5bf..c69f6ea9a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6329,3 +6329,8 @@ shape Open Otvoren # 3D Box add-on 3D Box 3D kutija 3D Box Tool Alat za 3D kutiju + +# Importer plug-ins (File › Open With, document.open) + Open With Otvori pomoću + No importer plug-ins installed Nisu instalirani dodaci za uvoz + Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 324750c44..222971582 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6430,3 +6430,20 @@ breakText Glyphs Glifs # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Eina Caixa 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Obre amb + No importer plug-ins installed No hi ha complements d’importació instal·lats + Read files you open in its format and make documents from them Llegir els fitxers que obriu en el seu format i fer-ne documents +@msg {_1} is an importer (File › Open With) {_1} és un importador (Fitxer › Obre amb) +@msg missing `importer` (see document.formats) falta `importer` (vegeu document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` ha de ser l’id de format d’un complement (vegeu document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` és un {_2}, no un importador +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}} +@msg the importer returned no objects l’importador no ha retornat cap objecte +@msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un importador declara d’1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 78e9cdd6e..eb3dd4da2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6444,3 +6444,20 @@ breakText Glyphs Glyfy # 3D Box add-on 3D Box 3D kvádr 3D Box Tool Nástroj 3D kvádr + +# Importer plug-ins (File › Open With, document.open) + Open With Otevřít pomocí + No importer plug-ins installed Nejsou nainstalovány žádné importní zásuvné moduly + Read files you open in its format and make documents from them Číst soubory, které otevřete v jeho formátu, a vytvářet z nich dokumenty +@msg {_1} is an importer (File › Open With) {_1} je importní zásuvný modul (Soubor › Otevřít pomocí) +@msg missing `importer` (see document.formats) chybí `importer` (viz document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` musí být ID formátu zásuvného modulu (viz document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ otevírá soubory {_1}, ne `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` je {_2}, ne importní zásuvný modul +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` je importní zásuvný modul: otevřete soubor pomocí document.open {{"importer"}"}} +@msg the importer returned no objects importní zásuvný modul nevrátil žádné objekty +@msg an importer returns `objects` and `warnings`, not `{key}` importní zásuvný modul vrací `objects` a `warnings`, ne `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads soubor je větší než {MAX_INPUT_BYTES} bajtů, které importní zásuvný modul čte +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` importní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul +@msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget` diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f72f29a09..a36911a35 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6296,3 +6296,8 @@ breakText Glyphs Glyffer # 3D Box add-on 3D Box 3D-boks 3D Box Tool 3D-boksværktøj + +# Importer plug-ins (File › Open With, document.open) + Open With Åbn med + No importer plug-ins installed Ingen import-plug-ins installeret + Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b22912c68..720f94a16 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6030,3 +6030,20 @@ breakText Glyphs Glyphen # 3D Box add-on 3D Box 3D-Quader 3D Box Tool 3D-Quader-Werkzeug + +# Importer plug-ins (File › Open With, document.open) + Open With Öffnen mit + No importer plug-ins installed Keine Import-Plug-ins installiert + Read files you open in its format and make documents from them Dateien lesen, die Sie in seinem Format öffnen, und daraus Dokumente erstellen +@msg {_1} is an importer (File › Open With) {_1} ist ein Import-Plug-in (Datei › Öffnen mit) +@msg missing `importer` (see document.formats) `importer` fehlt (siehe document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` muss die Format-ID eines Plug-ins sein (siehe document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ öffnet {_1}-Dateien, nicht `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` ist ein {_2}, kein Import-Plug-in +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` ist ein Import-Plug-in: Öffnen Sie eine Datei mit document.open {{"importer"}"}} +@msg the importer returned no objects das Import-Plug-in hat keine Objekte zurückgegeben +@msg an importer returns `objects` and `warnings`, not `{key}` ein Import-Plug-in gibt `objects` und `warnings` zurück, nicht `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads die Datei ist größer als die {MAX_INPUT_BYTES} Byte, die ein Import-Plug-in liest +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ein Import-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an +@msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an +@msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 71e4f499f..22a6511d3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6396,3 +6396,20 @@ breakText Glyphs Γλύφοι # 3D Box add-on 3D Box Κουτί 3D 3D Box Tool Εργαλείο κουτιού 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Άνοιγμα με + No importer plug-ins installed Δεν έχουν εγκατασταθεί πρόσθετα εισαγωγής + Read files you open in its format and make documents from them Ανάγνωση των αρχείων που ανοίγετε στη μορφή του και δημιουργία εγγράφων από αυτά +@msg {_1} is an importer (File › Open With) το {_1} είναι πρόσθετο εισαγωγής (Αρχείο › Άνοιγμα με) +@msg missing `importer` (see document.formats) λείπει το `importer` (δείτε document.formats) +@msg `importer` must be a plug-in format id (see document.formats) το `importer` πρέπει να είναι αναγνωριστικό μορφής πρόσθετου (δείτε document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` το «{shown}» ανοίγει αρχεία {_1}, όχι `{_2}` +@msg `{_1}` is a {_2}, not an importer το `{_1}` είναι {_2}, όχι πρόσθετο εισαγωγής +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} το `{_1}` είναι πρόσθετο εισαγωγής: ανοίξτε ένα αρχείο με document.open {{"importer"}"}} +@msg the importer returned no objects το πρόσθετο εισαγωγής δεν επέστρεψε κανένα αντικείμενο +@msg an importer returns `objects` and `warnings`, not `{key}` ένα πρόσθετο εισαγωγής επιστρέφει `objects` και `warnings`, όχι `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads το αρχείο είναι μεγαλύτερο από τα {MAX_INPUT_BYTES} byte που διαβάζει ένα πρόσθετο εισαγωγής +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εισαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions` +@msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 72ad8d982..d574f8efa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6386,3 +6386,20 @@ breakText Glyphs Glifos # 3D Box add-on 3D Box Caja 3D 3D Box Tool Herramienta Caja 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Abrir con + No importer plug-ins installed No hay plugins de importación instalados + Read files you open in its format and make documents from them Leer los archivos que abre en su formato y crear documentos a partir de ellos +@msg {_1} is an importer (File › Open With) {_1} es un plugin de importación (Archivo › Abrir con) +@msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` debe ser el id de formato de un plugin (consulte document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» abre archivos {_1}, no `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` es un {_2}, no un plugin de importación +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` es un plugin de importación: abra un archivo con document.open {{"importer"}"}} +@msg the importer returned no objects el plugin de importación no devolvió ningún objeto +@msg an importer returns `objects` and `warnings`, not `{key}` un plugin de importación devuelve `objects` y `warnings`, no `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el archivo supera los {MAX_INPUT_BYTES} bytes que lee un plugin de importación +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de importación declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f4eeaaff1..399c8e5fa 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6479,3 +6479,20 @@ breakText Glyphs Merkit # 3D Box add-on 3D Box 3D-laatikko 3D Box Tool 3D-laatikkotyökalu + +# Importer plug-ins (File › Open With, document.open) + Open With Avaa ohjelmalla + No importer plug-ins installed Tuontilaajennuksia ei ole asennettu + Read files you open in its format and make documents from them Lukea sen muodossa avaamasi tiedostot ja tehdä niistä asiakirjoja +@msg {_1} is an importer (File › Open With) {_1} on tuontilaajennus (Tiedosto › Avaa ohjelmalla) +@msg missing `importer` (see document.formats) `importer` puuttuu (katso document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` on oltava laajennuksen muototunnus (katso document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” avaa {_1}-tiedostoja, ei `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` on {_2}, ei tuontilaajennus +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` on tuontilaajennus: avaa tiedosto komennolla document.open {{"importer"}"}} +@msg the importer returned no objects tuontilaajennus ei palauttanut objekteja +@msg an importer returns `objects` and `warnings`, not `{key}` tuontilaajennus palauttaa `objects` ja `warnings`, ei `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tiedosto on suurempi kuin tuontilaajennuksen lukemat {MAX_INPUT_BYTES} tavua +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` tuontilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` vain vienti- tai tuontilaajennus ilmoittaa `extensions` +@msg an importer always gets an import's budget: leave `budget` out tuontilaajennus saa aina tuonnin budjetin: jätä `budget` pois diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d9e77d68d..78215fec9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6439,3 +6439,20 @@ breakText Glyphs Glyphes # 3D Box add-on 3D Box Boîte 3D 3D Box Tool Outil Boîte 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Ouvrir avec + No importer plug-ins installed Aucun module externe d’importation installé + Read files you open in its format and make documents from them Lire les fichiers que vous ouvrez dans son format et en faire des documents +@msg {_1} is an importer (File › Open With) {_1} est un module externe d’importation (Fichier › Ouvrir avec) +@msg missing `importer` (see document.formats) `importer` manquant (voir document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` doit être l’identifiant de format d’un module externe (voir document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` « {shown} » ouvre les fichiers {_1}, pas `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` est un {_2}, pas un module externe d’importation +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer"}"}} +@msg the importer returned no objects le module externe d’importation n’a renvoyé aucun objet +@msg an importer returns `objects` and `warnings`, not `{key}` un module externe d’importation renvoie `objects` et `warnings`, pas `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads le fichier dépasse les {MAX_INPUT_BYTES} octets qu’un module externe d’importation lit +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’importation déclare de 1 à {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions` +@msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b5791f4a4..38ac80141 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6892,7 +6892,7 @@ @msg Offset Path: the path is too complex to offset ऑफसेट पाथ: पाथ ऑफसेट करने के लिए बहुत जटिल है @msg on must be true or false on: पर सच होना चाहिए या गलत होना चाहिए @msg only a dock group above or below others collapses, not one beside another केवल दूसरों के ऊपर या नीचे एक डॉक समूह गिर जाता है, एक दूसरे के बगल में नहीं -@msg only an exporter declares `extensions` केवल निर्यातक `extensions` घोषित करता है +@msg only an exporter or an importer declares `extensions` केवल एक्सपोर्टर या इंपोर्टर `extensions` घोषित करता है @msg only an object of the Global Edit other than the one it started from can be left out केवल ग्लोबल एडिट का एक ऑब्जेक्ट, इसके अलावा इसे शुरू किया जा सकता है, छोड़ दिया जा सकता है @msg only groups, layers, type and images have a Contents (Characters) row केवल समूहों, लेयर्स, प्रकार और इमेज में एक सामग्री (Character) पंक्ति है @msg only http, https and mailto URLs open in the browser, not “{url}” केवल http, https और Mailto URL ब्राउज़र में खुलते हैं, न कि "{url}" @@ -7927,3 +7927,19 @@ swatch White, Black सफ़ेद, काला swatch Yellow पीला swatch Yellow Green पीला हरा theme Appearance अपीयरेंस + +# Importer plug-ins (File › Open With, document.open) + Open With इसके साथ खोलें + No importer plug-ins installed कोई इंपोर्टर प्लग-इन स्थापित नहीं है + Read files you open in its format and make documents from them उसके फ़ॉर्मेट में खोली गई फ़ाइलें पढ़ना और उनसे डॉक्यूमेंट बनाना +@msg {_1} is an importer (File › Open With) {_1} एक इंपोर्टर है (फ़ाइल › इसके साथ खोलें) +@msg missing `importer` (see document.formats) `importer` नहीं दिया गया (document.formats देखें) +@msg `importer` must be a plug-in format id (see document.formats) `importer` किसी प्लग-इन का फ़ॉर्मेट id होना चाहिए (document.formats देखें) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} फ़ाइलें खोलता है, `{_2}` नहीं +@msg `{_1}` is a {_2}, not an importer `{_1}` एक {_2} है, इंपोर्टर नहीं +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` एक इंपोर्टर है: document.open {{"importer"}"}} से फ़ाइल खोलें +@msg the importer returned no objects इंपोर्टर ने कोई ऑब्जेक्ट नहीं लौटाया +@msg an importer returns `objects` and `warnings`, not `{key}` इंपोर्टर `objects` और `warnings` लौटाता है, `{key}` नहीं +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads फ़ाइल उन {MAX_INPUT_BYTES} बाइट्स से बड़ी है जो इंपोर्टर पढ़ता है +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` इंपोर्टर 1 से {MAX_EXTENSIONS} `extensions` घोषित करता है +@msg an importer always gets an import's budget: leave `budget` out इंपोर्टर को हमेशा इंपोर्ट का बजट मिलता है: `budget` छोड़ दें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 58d649442..a4404ede3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6428,3 +6428,20 @@ breakText Glyphs Glifák # 3D Box add-on 3D Box 3D doboz 3D Box Tool 3D doboz eszköz + +# Importer plug-ins (File › Open With, document.open) + Open With Megnyitás ezzel + No importer plug-ins installed Nincs telepített importáló bővítmény + Read files you open in its format and make documents from them Beolvashatja a formátumában megnyitott fájlokat, és dokumentumokat készíthet belőlük +@msg {_1} is an importer (File › Open With) importáló bővítmény: {_1} (Fájl › Megnyitás ezzel) +@msg missing `importer` (see document.formats) hiányzik az `importer` (lásd document.formats) +@msg `importer` must be a plug-in format id (see document.formats) az `importer` egy bővítmény formátumazonosítója kell legyen (lásd document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” {_1} fájlokat nyit meg, nem `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}`: {_2}, nem importáló bővítmény +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` importáló bővítmény: nyisson meg egy fájlt a document.open {{"importer"}"}} paranccsal +@msg the importer returned no objects az importáló bővítmény nem adott vissza objektumot +@msg an importer returns `objects` and `warnings`, not `{key}` az importáló bővítmény `objects` és `warnings` értéket ad vissza, nem `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads a fájl nagyobb annál a {MAX_INPUT_BYTES} bájtnál, amit egy importáló bővítmény beolvas +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` az importáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg +@msg only an exporter or an importer declares `extensions` csak exportáló vagy importáló bővítmény adhat meg `extensions` értéket +@msg an importer always gets an import's budget: leave `budget` out az importáló bővítmény mindig egy importálás keretét kapja: hagyja el a `budget` kulcsot diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6211e49da..763c47a2a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6296,3 +6296,8 @@ breakText Glyphs Glif # 3D Box add-on 3D Box Kotak 3D 3D Box Tool Alat Kotak 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Buka Dengan + No importer plug-ins installed Tidak ada plug-in impor yang terinstal + Read files you open in its format and make documents from them Membaca file yang Anda buka dalam formatnya dan membuat dokumen darinya diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d11fcb22d..f4bde607f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6064,3 +6064,20 @@ breakText Glyphs Glifi # 3D Box add-on 3D Box Scatola 3D 3D Box Tool Strumento Scatola 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Apri con + No importer plug-ins installed Nessun plug-in di importazione installato + Read files you open in its format and make documents from them Leggere i file che apri nel suo formato e crearne documenti +@msg {_1} is an importer (File › Open With) {_1} è un plug-in di importazione (File › Apri con) +@msg missing `importer` (see document.formats) manca `importer` (vedi document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` deve essere l’id di formato di un plug-in (vedi document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» apre file {_1}, non `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` è un {_2}, non un plug-in di importazione +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` è un plug-in di importazione: apri un file con document.open {{"importer"}"}} +@msg the importer returned no objects il plug-in di importazione non ha restituito alcun oggetto +@msg an importer returns `objects` and `warnings`, not `{key}` un plug-in di importazione restituisce `objects` e `warnings`, non `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads il file supera i {MAX_INPUT_BYTES} byte che un plug-in di importazione legge +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di importazione dichiara da 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` solo un plug-in di esportazione o di importazione dichiara `extensions` +@msg an importer always gets an import's budget: leave `budget` out un plug-in di importazione riceve sempre il budget di un’importazione: ometti `budget` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6bb917eec..9415aad1f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6129,3 +6129,20 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3Dボックス 3D Box Tool 3Dボックスツール + +# Importer plug-ins (File › Open With, document.open) + Open With このアプリケーションで開く + No importer plug-ins installed 読み込みプラグインはインストールされていません + Read files you open in its format and make documents from them 独自形式で開いたファイルの読み取りとドキュメントの作成 +@msg {_1} is an importer (File › Open With) {_1} は読み込みプラグインです (「ファイル › このアプリケーションで開く」から使用してください) +@msg missing `importer` (see document.formats) `importer` がありません (document.formats を参照) +@msg `importer` must be a plug-in format id (see document.formats) `importer` にはプラグインの形式 ID を指定してください (document.formats を参照) +@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」が開くのは {_1} ファイルで、`{_2}` ではありません +@msg `{_1}` is a {_2}, not an importer `{_1}` は {_2} で、読み込みプラグインではありません +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` は読み込みプラグインです: document.open {{"importer"}"}} でファイルを開いてください +@msg the importer returned no objects 読み込みプラグインがオブジェクトを返しませんでした +@msg an importer returns `objects` and `warnings`, not `{key}` 読み込みプラグインが返すのは `objects` と `warnings` で、`{key}` ではありません +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ファイルが読み込みプラグインの読み取る {MAX_INPUT_BYTES} バイトを超えています +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 読み込みプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します +@msg only an exporter or an importer declares `extensions` `extensions` を宣言できるのは書き出しプラグインと読み込みプラグインだけです +@msg an importer always gets an import's budget: leave `budget` out 読み込みプラグインには常に読み込みの予算が割り当てられます: `budget` は省略してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 483955091..c6db1fce7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6456,3 +6456,20 @@ font-script Japanese იაპონური # 3D Box add-on 3D Box 3D ყუთი 3D Box Tool 3D ყუთის ხელსაწყო + +# Importer plug-ins (File › Open With, document.open) + Open With გახსნა პროგრამით + No importer plug-ins installed იმპორტის პლაგინები არ არის დაყენებული + Read files you open in its format and make documents from them მის ფორმატში გახსნილი ფაილების წაკითხვა და მათგან დოკუმენტების შექმნა +@msg {_1} is an importer (File › Open With) {_1} იმპორტის პლაგინია (ფაილი › გახსნა პროგრამით) +@msg missing `importer` (see document.formats) `importer` აკლია (იხ. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` უნდა იყოს პლაგინის ფორმატის ID (იხ. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ ხსნის {_1} ფაილებს და არა `{_2}`-ს +@msg `{_1}` is a {_2}, not an importer `{_1}` არის {_2} და არა იმპორტის პლაგინი +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` იმპორტის პლაგინია: გახსენით ფაილი document.open {{"importer"}"}}-ით +@msg the importer returned no objects იმპორტის პლაგინმა ობიექტები არ დააბრუნა +@msg an importer returns `objects` and `warnings`, not `{key}` იმპორტის პლაგინი აბრუნებს `objects`-ს და `warnings`-ს და არა `{key}`-ს +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ფაილი აღემატება {MAX_INPUT_BYTES} ბაიტს, რომელსაც იმპორტის პლაგინი კითხულობს +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` იმპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს +@msg only an exporter or an importer declares `extensions` `extensions`-ს მხოლოდ ექსპორტის ან იმპორტის პლაგინი აცხადებს +@msg an importer always gets an import's budget: leave `budget` out იმპორტის პლაგინი ყოველთვის იღებს იმპორტის ბიუჯეტს: გამოტოვეთ `budget` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9a6a465c9..090252435 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6025,3 +6025,20 @@ breakText Glyphs 글리프 # 3D Box add-on 3D Box 3D 상자 3D Box Tool 3D 상자 도구 + +# Importer plug-ins (File › Open With, document.open) + Open With 연결 프로그램 + No importer plug-ins installed 설치된 가져오기 플러그인 없음 + Read files you open in its format and make documents from them 자체 형식으로 연 파일을 읽고 문서로 만들기 +@msg {_1} is an importer (File › Open With) {_1}은(는) 가져오기 플러그인입니다(파일 › 연결 프로그램) +@msg missing `importer` (see document.formats) `importer`가 없습니다(document.formats 참조) +@msg `importer` must be a plug-in format id (see document.formats) `importer`는 플러그인의 형식 ID여야 합니다(document.formats 참조) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}”은(는) {_1} 파일을 엽니다. `{_2}`은(는) 열지 않습니다 +@msg `{_1}` is a {_2}, not an importer `{_1}`은(는) {_2}이며 가져오기 플러그인이 아닙니다 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}`은(는) 가져오기 플러그인입니다: document.open {{"importer"}"}}로 파일을 여세요 +@msg the importer returned no objects 가져오기 플러그인이 개체를 반환하지 않았습니다 +@msg an importer returns `objects` and `warnings`, not `{key}` 가져오기 플러그인은 `{key}`가 아닌 `objects`와 `warnings`를 반환합니다 +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 파일이 가져오기 플러그인이 읽는 {MAX_INPUT_BYTES}바이트보다 큽니다 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 가져오기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다 +@msg only an exporter or an importer declares `extensions` `extensions`는 내보내기 또는 가져오기 플러그인만 선언합니다 +@msg an importer always gets an import's budget: leave `budget` out 가져오기 플러그인은 항상 가져오기 예산을 받습니다: `budget`을 생략하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0328f1fc5..875bc733c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6342,3 +6342,8 @@ breakText Glyphs Glyphs # 3D Box add-on 3D Box 3D-doos 3D Box Tool 3D-doosgereedschap + +# Importer plug-ins (File › Open With, document.open) + Open With Openen met + No importer plug-ins installed Geen importplug-ins geïnstalleerd + Read files you open in its format and make documents from them Bestanden lezen die u in zijn indeling opent en er documenten van maken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f98e35520..37ad72246 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6299,3 +6299,20 @@ breakText Glyphs Glify # 3D Box add-on 3D Box Bryła 3D 3D Box Tool Narzędzie Bryła 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Otwórz za pomocą + No importer plug-ins installed Brak zainstalowanych wtyczek importu + Read files you open in its format and make documents from them Odczytywać pliki otwierane w jego formacie i tworzyć z nich dokumenty +@msg {_1} is an importer (File › Open With) {_1} to wtyczka importu (Plik › Otwórz za pomocą) +@msg missing `importer` (see document.formats) brak `importer` (zobacz document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` musi być identyfikatorem formatu wtyczki (zobacz document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” otwiera pliki {_1}, a nie `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` to {_2}, a nie wtyczka importu +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` to wtyczka importu: otwórz plik za pomocą document.open {{"importer"}"}} +@msg the importer returned no objects wtyczka importu nie zwróciła żadnych obiektów +@msg an importer returns `objects` and `warnings`, not `{key}` wtyczka importu zwraca `objects` i `warnings`, a nie `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads plik jest większy niż {MAX_INPUT_BYTES} bajtów, które odczytuje wtyczka importu +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka importu deklaruje od 1 do {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` deklaruje tylko wtyczka eksportu lub importu +@msg an importer always gets an import's budget: leave `budget` out wtyczka importu zawsze dostaje budżet importu: pomiń `budget` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ce6919f8d..8510a00c7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6517,3 +6517,20 @@ swatch Yellow Green Verde-amarelado # 3D Box add-on 3D Box Caixa 3D 3D Box Tool Ferramenta Caixa 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Abrir Com + No importer plug-ins installed Nenhum plug-in de importação instalado + Read files you open in its format and make documents from them Ler os arquivos que você abre no formato dele e criar documentos a partir deles +@msg {_1} is an importer (File › Open With) {_1} é um plug-in de importação (Arquivo › Abrir Com) +@msg missing `importer` (see document.formats) falta `importer` (consulte document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` deve ser o id de formato de um plug-in (consulte document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” abre arquivos {_1}, não `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` é um {_2}, não um plug-in de importação +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` é um plug-in de importação: abra um arquivo com document.open {{"importer"}"}} +@msg the importer returned no objects o plug-in de importação não retornou nenhum objeto +@msg an importer returns `objects` and `warnings`, not `{key}` um plug-in de importação retorna `objects` e `warnings`, não `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads o arquivo é maior que os {MAX_INPUT_BYTES} bytes que um plug-in de importação lê +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de importação declara de 1 a {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` só um plug-in de exportação ou de importação declara `extensions` +@msg an importer always gets an import's budget: leave `budget` out um plug-in de importação sempre recebe o orçamento de uma importação: omita `budget` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a1ae4746c..4c6aa7564 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6533,3 +6533,20 @@ shape Open Открытый # 3D Box add-on 3D Box 3D-параллелепипед 3D Box Tool Инструмент «3D-параллелепипед» + +# Importer plug-ins (File › Open With, document.open) + Open With Открыть с помощью + No importer plug-ins installed Модули импорта не установлены + Read files you open in its format and make documents from them Читать файлы, открываемые в его формате, и создавать из них документы +@msg {_1} is an importer (File › Open With) {_1} — модуль импорта (Файл › Открыть с помощью) +@msg missing `importer` (see document.formats) не указан `importer` (см. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` должен быть идентификатором формата модуля (см. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» открывает файлы {_1}, а не `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` — это {_2}, а не модуль импорта +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` — модуль импорта: откройте файл с помощью document.open {{"importer"}"}} +@msg the importer returned no objects модуль импорта не вернул ни одного объекта +@msg an importer returns `objects` and `warnings`, not `{key}` модуль импорта возвращает `objects` и `warnings`, а не `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл больше {MAX_INPUT_BYTES} байт, которые читает модуль импорта +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` модуль импорта объявляет от 1 до {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` объявляет только модуль экспорта или импорта +@msg an importer always gets an import's budget: leave `budget` out модуль импорта всегда получает бюджет импорта: не указывайте `budget` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b13141b8e..06d3ea547 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -7481,7 +7481,7 @@ digits Hindi Hindi @msg offset out of range förskjutningen ligger utanför intervallet @msg Offset Path: {e} Förskjut bana: {e} @msg Offset Path: the path is too complex to offset Förskjut bana: banan är för komplex för att förskjutas -@msg only an exporter declares `extensions` endast en exportfunktion deklarerar `extensions` +@msg only an exporter or an importer declares `extensions` endast en exportfunktion eller importfunktion deklarerar `extensions` @msg only groups, layers, type and images have a Contents (Characters) row endast grupper, lager, text och bilder har en rad för Innehåll (Tecken) @msg only interior mesh lines can be deleted endast inre nätlinjer kan raderas @msg only solid colours can be added (not gradients or patterns) endast enhetliga färger kan läggas till (inte gradienter eller mönster) @@ -7932,3 +7932,19 @@ digits Hindi Hindi @msg x and y must be given together x och y måste anges tillsammans @msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End) @msg zoom must be positive zoom måste vara positiv + +# Importer plug-ins (File › Open With, document.open) + Open With Öppna med + No importer plug-ins installed Inga importplugins är installerade + Read files you open in its format and make documents from them Läsa filer som du öppnar i dess format och skapa dokument av dem +@msg {_1} is an importer (File › Open With) {_1} är ett importinsticksprogram (Fil › Öppna med) +@msg missing `importer` (see document.formats) `importer` saknas (se document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` måste vara ett formatid för ett plugin (se document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” öppnar {_1}-filer, inte `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` är av typen {_2}, inte en importfunktion +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` är en importfunktion: öppna en fil med document.open {{"importer"}"}} +@msg the importer returned no objects importfunktionen returnerade inga objekt +@msg an importer returns `objects` and `warnings`, not `{key}` en importfunktion returnerar `objects` och `warnings`, inte `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads filen är större än de {MAX_INPUT_BYTES} byte som en importfunktion läser +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importfunktion anger 1 till {MAX_EXTENSIONS} `extensions` +@msg an importer always gets an import's budget: leave `budget` out en importfunktion får alltid en imports budget: utelämna `budget` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d3fc04823..995ed31ca 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6401,3 +6401,20 @@ breakText Glyphs Glifler # 3D Box add-on 3D Box 3B Kutu 3D Box Tool 3B Kutu Aracı + +# Importer plug-ins (File › Open With, document.open) + Open With Birlikte Aç + No importer plug-ins installed İçe aktarma eklentisi yüklü değil + Read files you open in its format and make documents from them Kendi biçiminde açtığınız dosyaları okuma ve bunlardan belge oluşturma +@msg {_1} is an importer (File › Open With) {_1} bir içe aktarma eklentisidir (Dosya › Birlikte Aç) +@msg missing `importer` (see document.formats) `importer` eksik (bkz. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` bir eklentinin biçim kimliği olmalıdır (bkz. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} dosyalarını açar, `{_2}` dosyasını değil +@msg `{_1}` is a {_2}, not an importer `{_1}` bir {_2}, içe aktarma eklentisi değil +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` bir içe aktarma eklentisidir: dosyayı document.open {{"importer"}"}} ile açın +@msg the importer returned no objects içe aktarma eklentisi hiç nesne döndürmedi +@msg an importer returns `objects` and `warnings`, not `{key}` içe aktarma eklentisi `{key}` değil, `objects` ve `warnings` döndürür +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads dosya, içe aktarma eklentisinin okuduğu {MAX_INPUT_BYTES} bayttan büyük +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` içe aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir +@msg only an exporter or an importer declares `extensions` `extensions` yalnızca dışa veya içe aktarma eklentisi bildirir +@msg an importer always gets an import's budget: leave `budget` out içe aktarma eklentisi her zaman bir içe aktarmanın bütçesini alır: `budget` değerini çıkarın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c256b0064..5044fe895 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6044,3 +6044,20 @@ breakText Glyphs Гліфи # 3D Box add-on 3D Box 3D-паралелепіпед 3D Box Tool Інструмент «3D-паралелепіпед» + +# Importer plug-ins (File › Open With, document.open) + Open With Відкрити за допомогою + No importer plug-ins installed Модулі імпорту не встановлено + Read files you open in its format and make documents from them Читати файли, які ви відкриваєте в його форматі, і створювати з них документи +@msg {_1} is an importer (File › Open With) {_1} - модуль імпорту (Файл › Відкрити за допомогою) +@msg missing `importer` (see document.formats) не вказано `importer` (див. document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` має бути ідентифікатором формату модуля (див. document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» відкриває файли {_1}, а не `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` - це {_2}, а не модуль імпорту +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` - модуль імпорту: відкрийте файл за допомогою document.open {{"importer"}"}} +@msg the importer returned no objects модуль імпорту не повернув жодного об’єкта +@msg an importer returns `objects` and `warnings`, not `{key}` модуль імпорту повертає `objects` і `warnings`, а не `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл більший за {MAX_INPUT_BYTES} байтів, які читає модуль імпорту +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` модуль імпорту оголошує від 1 до {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту +@msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 887d9a0f6..758e6799d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6045,3 +6045,20 @@ breakText Glyphs Glyph # 3D Box add-on 3D Box Hộp 3D 3D Box Tool Công cụ Hộp 3D + +# Importer plug-ins (File › Open With, document.open) + Open With Mở bằng + No importer plug-ins installed Không có plug-in nhập được cài đặt + Read files you open in its format and make documents from them Đọc các tệp bạn mở theo định dạng của nó và tạo tài liệu từ chúng +@msg {_1} is an importer (File › Open With) {_1} là plug-in nhập (Tệp › Mở bằng) +@msg missing `importer` (see document.formats) thiếu `importer` (xem document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` phải là id định dạng của một plug-in (xem document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” mở tệp {_1}, không phải `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` là {_2}, không phải plug-in nhập +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` là plug-in nhập: hãy mở tệp bằng document.open {{"importer"}"}} +@msg the importer returned no objects plug-in nhập không trả về đối tượng nào +@msg an importer returns `objects` and `warnings`, not `{key}` plug-in nhập trả về `objects` và `warnings`, không phải `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tệp lớn hơn {MAX_INPUT_BYTES} byte mà plug-in nhập đọc +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` plug-in nhập khai báo từ 1 đến {MAX_EXTENSIONS} `extensions` +@msg only an exporter or an importer declares `extensions` chỉ plug-in xuất hoặc nhập mới khai báo `extensions` +@msg an importer always gets an import's budget: leave `budget` out plug-in nhập luôn nhận ngân sách của một lần nhập: hãy bỏ `budget` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d99db0948..a43d1a88b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6285,3 +6285,20 @@ breakText Glyphs 字形 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Importer plug-ins (File › Open With, document.open) + Open With 打开方式 + No importer plug-ins installed 未安装导入增效工具 + Read files you open in its format and make documents from them 读取以其格式打开的文件并据此创建文档 +@msg {_1} is an importer (File › Open With) {_1} 是导入增效工具 (文件 › 打开方式) +@msg missing `importer` (see document.formats) 缺少 `importer` (参见 document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` 必须是增效工具的格式 ID (参见 document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” 打开的是 {_1} 文件,不是 `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是导入增效工具 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是导入增效工具:请用 document.open {{"importer"}"}} 打开文件 +@msg the importer returned no objects 导入增效工具未返回任何对象 +@msg an importer returns `objects` and `warnings`, not `{key}` 导入增效工具返回 `objects` 和 `warnings`,不是 `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 文件超过导入增效工具读取的 {MAX_INPUT_BYTES} 字节 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 导入增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions` +@msg only an exporter or an importer declares `extensions` 只有导出或导入增效工具才声明 `extensions` +@msg an importer always gets an import's budget: leave `budget` out 导入增效工具始终获得导入的预算:请省略 `budget` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb6b40a80..863bd31ba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6082,3 +6082,20 @@ breakText Glyphs 字符 # 3D Box add-on 3D Box 3D 盒子 3D Box Tool 3D 盒子工具 + +# Importer plug-ins (File › Open With, document.open) + Open With 開啟方式 + No importer plug-ins installed 未安裝匯入增效模組 + Read files you open in its format and make documents from them 讀取以其格式開啟的檔案並據此建立文件 +@msg {_1} is an importer (File › Open With) {_1} 是匯入增效模組 (檔案 › 開啟方式) +@msg missing `importer` (see document.formats) 未提供 `importer` (請參閱 document.formats) +@msg `importer` must be a plug-in format id (see document.formats) `importer` 必須是增效模組的格式 ID (請參閱 document.formats) +@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」開啟的是 {_1} 檔案,不是 `{_2}` +@msg `{_1}` is a {_2}, not an importer `{_1}` 是 {_2},不是匯入增效模組 +@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是匯入增效模組:請用 document.open {{"importer"}"}} 開啟檔案 +@msg the importer returned no objects 匯入增效模組未傳回任何物件 +@msg an importer returns `objects` and `warnings`, not `{key}` 匯入增效模組傳回 `objects` 和 `warnings`,不是 `{key}` +@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 檔案超過匯入增效模組讀取的 {MAX_INPUT_BYTES} 位元組 +@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` 匯入增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions` +@msg only an exporter or an importer declares `extensions` 只有匯出或匯入增效模組才宣告 `extensions` +@msg an importer always gets an import's budget: leave `budget` out 匯入增效模組一律取得匯入的預算:請勿指定 `budget` diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index d1e34e38b..41694fdbe 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -24,7 +24,7 @@ pub(crate) use export::{ pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; pub use open::{ MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, - open_path, open_reporting, opens_as, place_bytes, recent_files, report_open_error, + open_path, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error, }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 85ce45c40..f5bb617df 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -174,6 +174,7 @@ fn permission_words(name: &str) -> String { Some(permissions::Permission::State) => tl!("Keep data of its own with the document"), Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), + Some(permissions::Permission::ImportRead) => tl!("Read files you open in its format and make documents from them"), None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), }; words.to_string() @@ -256,6 +257,70 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { open_reporting(app, &path).map(|_| ()) } +/// File › Open With: opens the file `p` names (`path`), else one picked among the files with the +/// extensions the importer plug-in `importer` lists, with that importer, also when a built-in +/// format reads the extension (`document.open {importer}`); a failure is reported as File › Open +/// reports it. The web's file picker can't carry the importer: there File › Open's picker opens, +/// and the file opens as File › Open opens it. +pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { + let importer = p.get("importer").and_then(Value::as_str).ok_or("missing `importer` (see document.formats)")?; + let plugin = vectorcraft_plugins::import::named(importer).map_err(|e| e.to_string())?; + let path = match p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()) { + Some(path) => path.to_string(), + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => { + // The importer's format: its name and extensions, made once for each installation. + let filters = fileio::format(&vectorcraft_plugins::export::format_id(plugin.id())) + .filter(|f| f.read) + .map(|f| FilePick::filter(f.label, f.extensions)) + .into_iter() + .collect(); + pick_open(app, &FilePick { filters, folder: open_dialog_folder(app), ..Default::default() })? + } + }; + let opened = vectorcraft_engine::file_access::check_read(&path) + .and_then(|()| check_import_size(&path, true)) + .and_then(|()| read(app, &path)) + .and_then(|bytes| { + open_document(app, &path, &bytes, Some(path.clone()), &json!({ "importer": vectorcraft_plugins::export::format_id(plugin.id()) })) + }); + if let Err(e) = &opened { + report_open_error(app, &fileio::file_name(&path), e); + } + opened +} + +/// A file at `path` that only an importer plug-in reads (with `explicit`, the one named for it), +/// larger than the importer's input limit: an error before the file is read. A file whose start +/// shows a built-in format passes when no importer was named, since a built-in reader opens it. +#[cfg(not(target_arch = "wasm32"))] +fn check_import_size(path: &str, explicit: bool) -> Result<(), String> { + use std::io::Read as _; + use vectorcraft_plugins::import; + let ext = fileio::extension(path); + if !explicit && (fileio::readable_format(&ext).is_some() || import::for_extension(&ext).is_empty()) { + return Ok(()); + } + if std::fs::metadata(path).map_or(true, |m| m.len() <= import::MAX_INPUT_BYTES as u64) { + return Ok(()); + } + if !explicit { + let mut head = Vec::new(); + if let Ok(f) = std::fs::File::open(path) { + let _ = f.take(64 << 10).read_to_end(&mut head); + } + if fileio::detect(path, &head).is_some() { + return Ok(()); + } + } + Err(format!("can't open `{}`: {}", fileio::file_name(path), import::too_large())) +} + +#[cfg(target_arch = "wasm32")] +fn check_import_size(_: &str, _: bool) -> Result<(), String> { + Ok(()) +} + /// A file the user asked to open (File › Open, Open Recent, the Home screen, a drop, the Finder) /// couldn't be: say so in a dialog as well as the status bar, which is easily missed (#861). pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { @@ -353,6 +418,8 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) note_recent(app, path); return Ok(Value::Null); } + // A file only an importer plug-in reads is not read past the importer's input limit. + check_import_size(path, false)?; let mut bytes = read(app, path)?; if background && opens_as(path).is_none() { // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index b8d314346..d2f460834 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -138,16 +138,17 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool { ]; SLOTS.iter().any(|s| id.starts_with(s)) || saved_selection_slot(id).is_some() - || matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom") + || matches!(id, "text.setStyle" | "plugin.dialog" | "file.openWith" | "window.toolbar.custom") || (id == "window.workspace" && !crate::workspaces::is_builtin(label)) || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) } -/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or -/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds -/// while another thread installs or removes plug-ins. +/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter, an +/// Effect › Plug-ins effect or a File › Open With importer), by the plug-in's own name? Told by the +/// item itself, so it holds while another thread installs or removes plug-ins. pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { - id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() + matches!(id, "plugin.dialog" | "file.openWith") + || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() } /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 8ccb80c04..8031a98da 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -110,6 +110,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_path(app, &path), None => io::open_dialog(app).map(|_| Value::Null), }, + "file.openWith" => io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. "file.save" => io::save(app, SaveMode::Save, p, true), diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 3a55ab37c..46d695f16 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -28,6 +28,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("New…", "file.newDialog"), c("New from Template…", "file.newFromTemplate"), c("Open…", "file.open"), + sub("Open With", crate::dialogs::plugin::open_with_menu()), sub("Open Recent Files", { let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); v.push(Sep); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f4b29416d..5505556d1 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -30,6 +30,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+O", "{path?} → with a path, document.open's result {index, title, format, warnings, …} (null when a dialog asks first or a library loads)", ), + ( + "file.openWith", + "Open With", + "", + "{importer: \"plugin.\" (an importer plug-in's format id, see document.formats), path?} File › Open With: open the file with that importer (document.open's importer), also a file whose extension a built-in format reads; without a path pick one of the files with the extensions it lists (the web opens File › Open's picker, and the file opens as File › Open opens it) → document.open's result | null while the picker is open", + ), ( "file.save", "Save", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 92314bfba..28a4d9d11 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -68,6 +68,7 @@ pub(crate) enum Entry { /// document active at the time has closed meanwhile. const DOCUMENT_FREE: &[&str] = &[ "file.open", + "file.openWith", "file.newFromTemplate", "ui.installPlugin", "ui.checkScript", diff --git a/docs/gaps.md b/docs/gaps.md index 6780782ab..e2c7bfed7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -739,9 +739,14 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G17 Ecosystem: third-party plug-ins - **Missing:** Illustrator's ecosystem is a C++ plug-in SDK and a large commercial plug-in market. VectorCraft - has sandboxed WebAssembly plug-ins for object filters, live effects and file formats it writes - ([plugins.md](plugins.md)) and MCP; missing are tool plug-ins, panel plug-ins, importers, a plug-in directory and + has sandboxed WebAssembly plug-ins for object filters, live effects and file formats it writes or reads + ([plugins.md](plugins.md)) and MCP; missing are tool plug-ins, panel plug-ins, a plug-in directory and any third-party plug-ins (#710). +- **Done:** importer plug-ins (`"kind": "importer"`). A plug-in adds a read-only file format that File › Open lists + and File › Open With names; `document.open` uses it for a file no built-in format reads, or when `importer` names it. + It is given at most 128 MiB of the file and returns paths and compound paths, checked as a filter's output is, which + become a new document; nothing opens when it fails, declines or is cancelled. Engine, unit and headless Open With + regressions. - **Done:** exporter plug-ins (`"kind": "exporter"`). A plug-in adds a write-only file format that File › Export › Export As lists after the built-in ones and `document.export` writes (`plugin.`, or a path ending in one of its extensions). It is given the document as the native file's JSON and its parameters, with a larger budget than a diff --git a/docs/mcp.md b/docs/mcp.md index b65179035..ac4ea9cb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -437,7 +437,7 @@ opens the picker through `run_command` with the remote backend. | `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?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/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`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/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). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | 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). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, 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. | @@ -5985,8 +5985,17 @@ undo step; it may change, delete or add objects, and the result is selected). A 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 -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 +that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` +with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: +"plugin.", params?}` (and `open_file`) opens a file whose extension no built-in format reads, by its content or its +extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). +`importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such +as `svg`. The importer is given at most 128 MiB of the file; its objects become a new document whose artboard is +their bounds, Save then asks for a name, and `warnings` holds the importer's warnings. `plugin.run` of an importer is +an error that names `document.open`. In the app, File › Open With lists the installed importers, and +`file.openWith {importer, path?}` opens `path`, or a file picked among the importer's extensions, with one (on the web +it opens File › Open's picker). A failing plug-in returns an error and leaves the document as it was (an export writes +no file, and an import opens no document). 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` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) @@ -6004,6 +6013,7 @@ value or the folder the preference already holds, and the web build does not che {"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/plot.hpgl","importer":"plugin.org.example.hpgl"}}} ``` ## Colour adjustments diff --git a/docs/plugins.md b/docs/plugins.md index 96f8ac6e3..e92a5a11e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -5,7 +5,7 @@ arbitrary machine code and cannot work in the browser build. Instead, plug-ins a run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. -There are three kinds of plug-in: +There are four kinds of plug-in: - **Object filters** read the selected paths and compound paths (geometry, fills, strokes, gradients, opacity) and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path @@ -15,6 +15,9 @@ There are three kinds of plug-in: - **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. +- **Importers** read files in a format of their own, such as a plotter's or a cutting machine's, into a new document of + paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host + hands them the file's bytes. This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). @@ -38,6 +41,16 @@ An exporter adds a write-only format, `plugin.`, which `document.formats` li `document.export {"format": "plugin.", "params"?: {…}, "path"?}` writes it, and so does a `path` ending in one of its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. +An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id, its extensions +in `openExtensions`. `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format +reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the +one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, +which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` +only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an +importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high); +the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are +VectorCraft's own. + In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns, text, lists @@ -45,7 +58,10 @@ of numbers and colors, labelled, explained and sorted into sections as its manif parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, which keeps its heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the -same kind once the file is picked, and its Export button writes the file. **File › Open** of a `.wasm` file installs +same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › +Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the +installed importers by name (`file.openWith {"importer", "path"?}`), and each opens a file picked among its extensions +with that importer (on the web, File › Open's picker opens instead). **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). @@ -71,20 +87,22 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | -| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export 10 G + 4,000 per byte | `Limit("instruction budget")` | -| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run or export (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run`; a [heavy filter](#heavy-filters) 1 G + 4,000 per byte; an export or import 10 G + 4,000 per byte | `Limit("instruction budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run, 300 s per heavy filter run, export or import (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Import input | 128 MiB of the file (`vectorcraft_plugins::import::MAX_INPUT_BYTES`) | the file doesn't open; a larger file named by `path` is not read past that | | Call depth | 1,024 frames | trap | | Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | An export runs where the export runs: with Background Export on, on the export's worker, whose Cancel stops the -plug-in between two of those 20 M-instruction slices; nothing is written then. +plug-in between two of those 20 M-instruction slices; nothing is written then. An import stops the same way when the +open it runs in is cancelled, and nothing opens then. ### Heavy filters Some filters do heavy but legitimate work, such as routing a plotter's pen, packing shapes onto a sheet or running a simulation, which a minute and 50 M instructions don't cover. Such a filter declares `"budget": "heavy"` in its -manifest (only a filter may: a live effect runs whenever the document is drawn, and an exporter has an export's -budget). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s +manifest (only a filter may: a live effect runs whenever the document is drawn, an exporter has an export's budget and +an importer an import's). Each of its runs gets 1 G instructions (20 times a light run's) plus the 4,000 per input byte, and 300 s (`vectorcraft_plugins::{HEAVY_FUEL, HEAVY_WALL_TIME_MS}`). Its output can't add to the budget: the host learns how much there is only once the run has ended. @@ -115,12 +133,14 @@ capability model proposed for PhotoCraft plug-ins | `state` | filter | Keep data of its own with the document (`documentData`). | | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | | `export.write` | exporter | Write the document as a file in its own format. | +| `import.read` | importer | Read files you open in its format and make documents from them. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and - `export.write`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. A filter whose list is - empty may do none of these, and also installs without a prompt. -- An effect or an exporter that lists permissions lists both of its implied ones. A filter may list any of its three. + `export.write`; an importer `import.read`. Such a plug-in installs without a prompt. This rule is VectorCraft's own. + A filter whose list is empty may do none of these, and also installs without a prompt. +- An effect, an exporter or an importer that lists permissions lists all of its implied ones. A filter may list any of + its three. An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names kept for a later plug-in API (`progress`, `ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. @@ -197,16 +217,17 @@ pointers are offsets into the module's memory. - `id`: 1–64 characters from `A-Z a-z 0-9 . _ -` (reverse-DNS recommended). Installing a module with the same id replaces the old one. A live effect's effect id is `plugin.`. -- `name`: the menu label, or an exporter's format name in Export As (1–64 printable characters). `kind`: `"filter"` - (object filter), `"effect"` (live effect) or `"exporter"`. Exporters came after the first ABI v1 hosts, which refuse - the kind when the plug-in is installed. +- `name`: the menu label, or an exporter's or importer's format name in Export As, Open and Open With (1–64 printable + characters). `kind`: `"filter"` (object filter), `"effect"` (live effect), `"exporter"` or `"importer"`. Exporters + and importers came after the first ABI v1 hosts, which refuse the kind when the plug-in is installed. - `budget` (filters only): `"light"` (the default) or `"heavy"`, for work that takes minutes ([heavy 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. -- `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. +- `extensions` (exporters and importers 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, and + `document.open` reads a file with the importer only when `importer` names it. - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or `preview`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. Numeric defaults @@ -255,6 +276,8 @@ appearance and effect record, artboard, swatch and symbol, so an exporter reads `version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`. +An **importer** gets the bytes of the file being opened, as they are (not JSON), at most 128 MiB of them. + ```json {"objects": [ {"id": 12, "type": "path", "name": "Leaf", @@ -299,14 +322,16 @@ type and `blob` but without their data, which follows the JSON in a saved file; "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792], "documentData": {"pens": 4}}} ``` -`mode` is `"filter"`, `"effect"` or `"export"`; `bounds` the selection's geometric bounds (a live effect: the reference +`mode` is `"filter"`, `"effect"`, `"export"` or `"import"`; `bounds` the selection's geometric bounds (a live effect: the reference box of the effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the selection's centre, else the first; `documentData` (filters) what the plug-in last kept with the document (`null` until it keeps something; see Output). A live effect's `item` says where it sits: `"object"` (one of the object's effects), or `{"fill": i}` / `{"stroke": i}` (in the own effects of the object's `i`th fill or stroke, counted from the bottom as in `fills` and `strokes`). An export's `_context` is `{"mode": "export", "extension": "plt", "name": "Poster"}` instead: the extension of the file being written (one the exporter lists) and its name without the -extension (the document's name when the export is not given a path), which some formats keep in their header. +extension (the document's name when the export is not given a path), which some formats keep in their header. An +import's `_context` is `{"mode": "import", "extension": "plt", "name": "Poster"}`: the extension of the file being +opened and its name without the extension. ### Output @@ -357,6 +382,11 @@ An **exporter** returns `{"dataBase64": "…", "warnings"?: ["…"]}`: the file, is, and what the export left out or changed, for the user (at most 100 warnings of at most 500 characters each, shown with the export's own). A decline (`{"error": …}`), a missing `dataBase64` or data that isn't base64 writes nothing. +An **importer** returns `{"objects": [...], "warnings"?: ["…"]}`: new paths and compound paths (no `id`) in the +filters' object form, checked as a filter's new objects are, with warnings capped as an exporter's are and shown with +the open's own. An output with no objects, with keys other than `objects`, `warnings` and `error`, or with an object +that fails the checks opens nothing, and neither does a decline (`{"error": …}`). + ## Writing a plug-in in Rust [`crates/plugins/example`](../crates/plugins/example) is a complete object filter, **Desaturate**: it moves every @@ -386,8 +416,8 @@ per change of their input, parameters or paints (results are cached), not per fr ## Not in v1 - 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). + callbacks (a plug-in reporting progress or asking whether it was cancelled), and importers that place files + (File › Place reads only the built-in formats) or make type, images, layers or artboards. - Live effects that change paint (they reshape geometry; an object filter recolours), 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 88a2a8707..36fca35a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1439,6 +1439,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged. +- **Importer plug-ins (G17):** a WebAssembly plug-in of kind `importer` adds a read-only file format, as [the help page](https://ai-sdk-2020.84.nz/html/struct_a_i_file_format_suite) describes for file format plug-ins that read: File › Open lists its files under "All readable files" and its own filter, and File › Open With lists each importer by name. `document.open` uses it for a file whose extension no built-in format reads, by content or extension (the importer whose id sorts first, when several list the extension), or the one `importer` names, also for an extension a built-in format reads; a damaged file of a built-in format reports that format's error. The plug-in is given at most 128 MiB of the file and `_context` (`mode: "import"`, `extension`, `name`), and returns paths and compound paths in the filters' object JSON, checked as a filter's output is; they become a new document whose artboard is their bounds, which Save asks a name for. A decline, invalid output, a trap or Cancel opens nothing. The routing rule, the limit and Open With are VectorCraft's own. Regressions: `crates/engine/tests/plugin_import.rs`, `crates/plugins/src/import.rs`, `crates/ui-egui/src/dialogs/tests_plugin_import.rs`. Scores and hours unchanged. - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged. From b109378ae6861f7ce70391ea12014185ca6a1c7d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 23:22:11 -0800 Subject: [PATCH 43/82] 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 ba1abb73b0091186cce446ae2ed0dd18a9d82a82 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:51:30 -0500 Subject: [PATCH 44/82] .ai import: tests for Pattern brushes' number counts and an older Art brush's first number A Pattern brush reads its scale from 3 or more numbers (the older form's 8 too) and isn't read with fewer; an 8-number Art brush is the older form whatever its first number. The module notes say the count tells the form. --- crates/eps/src/import/ai/brush.rs | 12 +++++++----- crates/eps/src/import/ai/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index bd5a1628a..8e1f27bad 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -28,10 +28,11 @@ //! [`vectorcraft_brush::ArtBrush`]'s `overlap: false`). //! - A Scatter, Art or Pattern brush's parameters are fields split by `/`: a number, the art's //! section names (one for Scatter, five for Art and Pattern, empty where there is none, each -//! after a space for Art and Pattern), more fields, and last 15 numbers. Brushes whose first -//! number is 1, as older files write them, have one field fewer before the numbers and 8 numbers; -//! an Art brush in that form is read from its art's name alone (its numbers aren't known, so it -//! keeps the defaults and its paths their drawn look). +//! after a space for Art and Pattern), more fields, and last 15 numbers. Older files write +//! brushes with first number 1, one field fewer before the numbers and 8 numbers; an Art brush +//! with 8 numbers, whatever its first number, is read from its art's name alone (its numbers +//! aren't known, so it keeps the defaults and its paths their drawn look). A Pattern brush needs +//! only its first 3 numbers (its scale is the third). //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the //! rotation follows the path (14), and 1 Tints (1). @@ -339,7 +340,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< } Tool::Art => { // The older form's 8 numbers aren't known: its brush keeps the defaults and its - // paths their drawn look. Any other count isn't a form this reads. + // paths their drawn look. The count tells the form, not the first number. + // Any other count isn't a form this reads. let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) { (if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15)) } else if n.len() == 8 { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c69728434..9a3227113 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -8225,6 +8225,10 @@ fn an_older_art_brush_comes_in_with_its_art() { assert_eq!(brush::parse_tool_object(brush::Tool::Art, "Ribbon", bad), Err(brush::BrushError::Values), "{bad}"); } assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values)); + // 8 numbers are the older form whatever the first number says: read with the defaults. + let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap(); + let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") }; + assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization)); let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat(); let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer(""))); let lib = vectorcraft_brush::library(&r.doc); @@ -8234,6 +8238,26 @@ fn an_older_art_brush_comes_in_with_its_art() { assert!(r.warnings.iter().any(|w| w.ends_with("Brushes panel (1)")), "{:?}", r.warnings); } +/// A Pattern brush needs only its first 3 numbers, its scale the third (as a percentage), so the +/// older form's 8 numbers read too; fewer than 3 aren't read. +#[test] +fn a_pattern_brush_reads_its_scale_from_three_or_more_numbers() { + const SLOTS: &str = "1 / Tile one/ Tile two/ / / Tile five/ 0 1 / "; + for numbers in ["1 0 0.5", "1 0 0.5 0 1 0 0 0"] { + let d = brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")).unwrap(); + let brush::DraftKind::Pattern { tiles, scale } = d.kind else { panic!("{d:?}") }; + assert_eq!(tiles[1].as_deref(), Some("Tile two"), "{numbers}"); + assert!((scale - 50.0).abs() < 1e-9, "{numbers}: {scale}"); + } + for numbers in ["", "1", "1 0"] { + assert_eq!( + brush::parse_tool_object(brush::Tool::Pattern, "Fence", &format!("{SLOTS}{numbers}")), + Err(brush::BrushError::Values), + "{numbers:?}" + ); + } +} + /// A brush's art filled with a pattern the file defines before it is read, with that pattern. #[test] fn a_brushs_art_takes_the_patterns_defined_before_it() { From 9d706a9a6d6f04f78d5117c38f205d8011ec8013 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:41:09 -0500 Subject: [PATCH 45/82] Parity doc: paths with an older Art brush keep their drawn look --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ed26d5bf..c06d85d53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1279,7 +1279,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). - A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`). From 9316c17967a9fdaa8233c9c73bf7002ab7f0ee40 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 22:00:16 -0800 Subject: [PATCH 46/82] .ai save: the editing copy stops at the reader's object limit The reader stops a whole file past 4,000,000 objects (MAX_NODES), and the writer had no limit of its own. The writer now counts the objects it writes as the reader counts them, across layers, symbol and pattern definitions, opacity masks and art styles, and leaves out art past the limit with a warning. Art that does not fit is given back as a whole: a mask, a pattern or a symbol definition is left out with all its art, and a symbol's definition is written only with room for its instance. The limit is the reader's own constant, re-exported as MAX_EDITING_OBJECTS. --- crates/eps/src/ai_editing.rs | 9 +- crates/eps/src/ai_editing/tests.rs | 162 ++++++++++++ crates/eps/src/ai_editing/writer.rs | 268 ++++++++++++++++---- crates/eps/src/ai_editing/writer/images.rs | 5 + crates/eps/src/ai_editing/writer/paints.rs | 13 +- crates/eps/src/ai_editing/writer/styles.rs | 15 ++ crates/eps/src/ai_editing/writer/symbols.rs | 25 +- crates/eps/src/ai_editing/writer/texts.rs | 6 + crates/eps/src/import.rs | 4 +- crates/eps/src/import/ai.rs | 4 +- crates/eps/src/import/ai/read.rs | 9 + crates/eps/src/import/ai/read/types.rs | 4 +- 12 files changed, 472 insertions(+), 52 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 828145c9c..9ae80c3c0 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -167,7 +167,8 @@ //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on //! layers and opacity masks nested too deep, and, on objects written without an art style, live //! effects other than geometry effects left out; graphic styles, links to them and every art style -//! on a large canvas are left out. +//! on a large canvas are left out; art past [`crate::import::MAX_EDITING_OBJECTS`] objects in all +//! (the most the reader reads), counted as the reader counts them, is left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -240,6 +241,11 @@ pub struct AiEditingOutput { /// Write `doc` as the editing data of a `.ai` file: every artboard and layer. pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result { + export_within(doc, o, crate::import::MAX_EDITING_OBJECTS) +} + +/// [`export_ai_editing`] writing at most `max_objects` objects, counted as the reader counts them. +fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Result { let board = doc.artboards.first().ok_or("the document has no artboard")?.rect; if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); @@ -280,6 +286,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Vec<(&'static str, Document)> { + vec![ + ("sample", sample()), + ("unsupported", unsupported()), + ("transparent", transparent()), + ("gradients and patterns", gradients_and_patterns()), + ("images", images_doc()), + ("typed", typed()), + ("variables", variables_doc()), + ("symbols", symbols_document()), + ("styles", styles_document()), + ("painted clips", painted_clips()), + ("blend", blend_doc()), + ] +} + +/// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps. +fn blend_doc() -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let keys = vec![ + Arc::new(square(&mut doc, Rect::new(10.0, 10.0, 12.0, 12.0), Color::gray(0.5))), + Arc::new(square(&mut doc, Rect::new(30.0, 10.0, 32.0, 12.0), Color::gray(0.5))), + ]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() }; + let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }); + doc.insert(Some(layer), usize::MAX, blend).unwrap(); + doc +} + +/// Clipping groups whose clipping paths are filled: a square with a color, and a ring (a path of +/// two subpaths) with a color, with a gradient, and with a color and a gradient stroke. +fn painted_clips() -> Document { + let mut doc = Document::new(260.0, 100.0); + let layer = doc.layers[0].id; + let ring = |x: f64| { + let mut p = shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)); + p.subpaths.extend(shapes::rectangle(Rect::new(x + 5.0, 15.0, x + 15.0, 25.0)).subpaths); + p + }; + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); + let shade = || Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let clips = [ + (10.0, shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None), + (70.0, ring(70.0), solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None), + (130.0, ring(130.0), shade(), Paint::None), + (190.0, ring(190.0), solid(Color::rgb(0.0, 1.0, 0.0)), shade()), + ]; + for (x, path, fill, stroke) in clips { + let mut clip = clipping(&mut doc, path); + let width = if matches!(stroke, Paint::None) { 0.0 } else { 2.0 }; + clip.appearance = Appearance::basic(fill, stroke, width).into(); + let inside = square(&mut doc, Rect::new(x - 5.0, 5.0, x + 10.0, 20.0), Color::gray(0.5)); + let g = group(&mut doc, vec![clip, inside], true); + doc.insert(Some(layer), usize::MAX, g).unwrap(); + } + doc +} + +/// The writer counts the objects it writes as the reader counts the objects it reads: with room +/// for as many objects as the reader reads, nothing is left out; with one less, art is left out, +/// with a warning. With room for fewer, the reader never reads more objects than there is room for. +#[test] +fn the_writer_counts_objects_as_the_reader_reads_them() { + let objects = |out: &AiEditingOutput| crate::import::objects_read(&out.program).unwrap(); + let limited = |out: &AiEditingOutput| out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT); + for (name, doc) in counted() { + let all = export_ai_editing(&doc, &opts()).unwrap(); + let read = objects(&all); + assert!(read > 0 && !limited(&all), "{name}: {read} objects, {:?}", all.warnings); + let room = export_within(&doc, &opts(), read).unwrap(); + assert!(!limited(&room), "{name}: nothing is left out with room for {read} objects"); + assert_eq!(room.program, all.program, "{name}"); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + assert!(limited(&out), "{name}: art is left out with room for {max} of {read} objects"); + assert!(objects(&out) <= max, "{name}: {} objects read with room for {max}", objects(&out)); + } + } +} + +/// An opacity mask whose art doesn't all fit within the object limit is left out with its art, +/// and the object it masks is written without it; the art left out leaves room for the objects +/// after it. +#[test] +fn a_mask_whose_art_passes_the_object_limit_is_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut masked = square(&mut doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::gray(0.5)); + let a = square(&mut doc, Rect::new(0.0, 0.0, 30.0, 30.0), Color::rgb(1.0, 1.0, 1.0)); + let b = square(&mut doc, Rect::new(30.0, 30.0, 60.0, 60.0), Color::rgb(1.0, 1.0, 1.0)); + let art = group(&mut doc, vec![a, b], false); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + doc.insert(Some(layer), usize::MAX, masked).unwrap(); + let after = square(&mut doc, Rect::new(100.0, 10.0, 140.0, 50.0), Color::gray(0.5)); + doc.insert(Some(layer), usize::MAX, after).unwrap(); + let text = |max: usize| { + let out = export_within(&doc, &opts(), max).unwrap(); + let limited = out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT); + (String::from_utf8_lossy(&out.program).replace("\r\n", "\n"), limited) + }; + let (all, limited) = text(4); + assert!(!limited && all.contains("(AI9_Mask)"), "{all}"); + let (short, limited) = text(2); + assert!(limited); + assert!(!short.contains("(AI9_Mask)") && !short.contains("%_/ArtDictionary :\n%_/Mask"), "{short}"); + assert_eq!(short.matches("\nf\n").count(), 2, "both squares are written: {short}"); +} + +/// A symbol's definition is written only when there is room for its instance too. +#[test] +fn a_symbol_definition_is_written_only_with_room_for_its_instance() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let a = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::gray(0.5)); + let b = square(&mut doc, Rect::new(10.0, 10.0, 20.0, 20.0), Color::gray(0.5)); + let art = group(&mut doc, vec![a, b], false); + doc.symbols.push(Symbol { dynamic: false, name: "Pair".into(), art: Arc::new(art) }); + let placed = instance(&mut doc, "Pair", Affine::translate((50.0, 10.0))); + doc.insert(Some(layer), usize::MAX, placed).unwrap(); + add_rect(&mut doc, Rect::new(100.0, 10.0, 110.0, 20.0)); + let program = export_ai_editing(&doc, &opts()).unwrap().program; + let all = String::from_utf8_lossy(&program).replace("\r\n", "\n"); + assert!(all.contains("%AI14_BeginSymbol") && all.contains("/SymbolInstance :"), "{all}"); + let read = crate::import::objects_read(&program).unwrap(); + for max in 0..read { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + let defined = text.contains("%AI14_BeginSymbol"); + assert_eq!(defined, text.contains("/SymbolInstance :"), "room for {max} of {read}: {text}"); + } +} + +/// The writer's limit is the reader's, which its warning names. +#[test] +fn the_object_limit_is_the_readers() { + assert_eq!(crate::import::MAX_EDITING_OBJECTS, 4_000_000); + assert!(writer::OBJECT_LIMIT.contains("4,000,000")); +} + +/// Paths past the object limit are left out of a layer of many paths, with a warning: the paths +/// that fit are written. +#[test] +fn paths_past_the_object_limit_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + for i in 0..10 { + add_rect(&mut doc, Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0)); + } + let out = export_within(&doc, &opts(), 6).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + assert_eq!(crate::import::objects_read(&out.program).unwrap(), 6); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert_eq!(text.matches("\nf\n").count(), 6, "the first six paths are written"); +} + #[test] fn a_stained_symbol_instance_is_written_in_its_stained_colors() { let mut doc = Document::new(200.0, 100.0); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 5bb33aa52..90182c8fd 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -62,6 +62,7 @@ const GROUPED_LAYER: &str = pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; pub(super) const SWATCHES: &str = "freeform gradient swatches, gradient swatches of more than 256 stops, and pattern swatches whose patterns the data can't define, are left out of the editing data for other apps"; pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; +pub(super) const OBJECT_LIMIT: &str = "art past 4,000,000 objects in all is left out of the editing data for other apps"; const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; /// The transparency `mode opacity isolate knockout shape Xy` sets: the blending mode ([`blend_code`]), @@ -117,6 +118,16 @@ fn blend_code(b: BlendMode) -> u8 { } } +/// What the writer had charged when a part of the art started ([`Writer::charges`]). +#[derive(Clone, Copy)] +struct Charges { + objects: usize, + symbol_nodes: u64, + kept_objects: usize, + kept_symbol_nodes: u64, + left_out: usize, +} + pub(super) struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -144,6 +155,18 @@ pub(super) struct Writer<'a> { brushes: Option>, /// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`]. symbol_nodes: u64, + /// The objects written so far, counted as the reader counts the objects it reads. + objects: usize, + /// How many times art was left out for [`Self::max_objects`]. + left_out: usize, + /// The objects of the pattern and symbol definitions written so far, which stay written when + /// the art that first used them is not ([`Self::give_back`]). + definition_objects: usize, + /// The objects of symbol art in those definitions ([`Self::symbol_nodes`]). + definition_symbol_nodes: u64, + /// The most objects written ([`Self::take_objects`]): the most the reader reads, + /// [`crate::import::MAX_EDITING_OBJECTS`]. + pub(super) max_objects: usize, /// The document → the space gradients' matrices (`Xm`, `Bm`, `Bc`) and focal points are /// written in: art space, and in a pattern's definition [`Self::tile_grad_space`]. grad_space: Affine, @@ -286,6 +309,85 @@ impl<'a> Writer<'a> { styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, + objects: 0, + left_out: 0, + definition_objects: 0, + definition_symbol_nodes: 0, + max_objects: crate::import::MAX_EDITING_OBJECTS, + } + } + + /// Whether `count` more objects may be written: the objects written so far, counted as the + /// reader counts them ([`object_count`]), stay within [`Self::max_objects`]. Past it the art is + /// left out, with a warning. + fn take_objects(&mut self, count: usize) -> bool { + let total = self.objects.saturating_add(count); + if total > self.max_objects { + self.warn(OBJECT_LIMIT); + self.left_out += 1; + return false; + } + self.objects = total; + true + } + + /// Start a group with `op` (`u`, or `q` for a clipping group), counted as one object + /// ([`Self::take_objects`]). False, writing nothing, when there is no room for it. + fn open_group(&mut self, op: &str) -> bool { + if !self.take_objects(1) { + return false; + } + self.line(op); + true + } + + /// Whether `n`, an instance of `symbol` placed by `xf`, is written as a live instance + /// ([`Self::live_instance`]). The instance is one object, without the art of its definition, + /// and room for it is kept before the definition is written. + fn live_instance_in_room(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool { + if !self.take_objects(1) { + return false; + } + if self.live_instance(n, symbol, xf) { + return true; + } + self.objects -= 1; + false + } + + /// What has been charged so far, for [`Self::keep_since`] and [`Self::give_back`]. + fn charges(&self) -> Charges { + Charges { + objects: self.objects, + symbol_nodes: self.symbol_nodes, + kept_objects: self.definition_objects, + kept_symbol_nodes: self.definition_symbol_nodes, + left_out: self.left_out, + } + } + + /// Whether art was left out for [`Self::max_objects`] since `c`. + fn left_out_since(&self, c: Charges) -> bool { + self.left_out != c.left_out + } + + /// A definition that started at `c` is written: what was charged since stays charged when the + /// art that first used it is given back ([`Self::give_back`]). + fn keep_since(&mut self, c: Charges) { + self.definition_objects = c.kept_objects + (self.objects - c.objects); + self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes); + } + + /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are + /// given back, except those of the definitions written since, and the images `lines` marks + /// are emptied (the others keep their indices). + fn give_back(&mut self, c: Charges, lines: &str) { + self.objects = c.objects + (self.definition_objects - c.kept_objects); + self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + for index in lines.split(PAYLOAD).skip(1).step_by(2) { + if let Some(p) = index.parse::().ok().and_then(|i| self.payloads.get_mut(i)) { + *p = Vec::new(); + } } } @@ -510,19 +612,22 @@ impl<'a> Writer<'a> { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), NodeKind::Group { children, clip: false } => { - self.line("u"); - for c in children { - self.node(c); + if self.open_group("u") { + for c in children { + self.node(c); + } + self.end_group("U", xy); } - self.end_group("U", xy); } NodeKind::Path { path, rule, guide, .. } => { if *guide { - // Its own transparency, not the one the object before it set. - if path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) { + // A path for each subpath with points, with the guide's own transparency, not + // the one the object before it set. + let subpaths = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count(); + if subpaths > 0 && self.take_objects(subpaths) { self.set_object_xy(xy.object()); + ai_ops::guide(&mut self.body, self.to_art, path, true); } - ai_ops::guide(&mut self.body, self.to_art, path, true); } else { self.shape(n, path, *rule, xy); } @@ -530,8 +635,11 @@ impl<'a> Writer<'a> { NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => self.image(im, xy), - NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), + NodeKind::SymbolInstance { symbol, xf } if self.live_instance_in_room(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { + if !self.open_group("u") { + return; + } // The canvas places an instance inside a symbol's art where the outer instance puts // it (9-slice guides, stains and patterns included), and this art doesn't move with it. self.wrote_fixed |= self.in_symbol; @@ -541,7 +649,6 @@ impl<'a> Writer<'a> { } else { self.warn(symbols::MISSING_SYMBOL); } - self.line("u"); if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { @@ -583,8 +690,10 @@ impl<'a> Writer<'a> { | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { + if !self.open_group("u") { + return; + } self.warn(LIVE); - self.line("u"); if self.nest < MAX_NEST { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.nest += 1; @@ -629,11 +738,14 @@ impl<'a> Writer<'a> { if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() { return; } + let start = self.body.len(); + let has_id = name.is_some() || !bound.is_empty(); + let has_entries = entries.is_some(); self.body.push_str("%_/ArtDictionary :\n"); if let Some(e) = entries { self.body.push_str(&e); } - if name.is_some() || !bound.is_empty() { + if has_id { let id = self.take_id(name.unwrap_or("XMLID")); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); if name.is_none() { @@ -644,8 +756,13 @@ impl<'a> Writer<'a> { self.bound.push((n.id, id, bound)); } } - if let Some(m) = mask { - self.mask(m); + // A mask whose art doesn't fit is left out, and with nothing else in it the dictionary too. + if let Some(m) = mask + && !self.mask(m) + && !has_id + && !has_entries + { + return self.body.truncate(start); } self.body.push_str("%_;\n%_\n"); } @@ -673,10 +790,13 @@ impl<'a> Writer<'a> { /// An opacity mask, as an entry of its object's dictionary: `/Mask :`, `X=`, its art on `%_` /// lines (from the state a dictionary's art starts in: unlocked, shown, opaque), `X+`, `(Art) ,`, /// its options as the `/Bool` entries `Clipping`, `Inverted`, `Disabled` and `Linked`, and - /// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. - fn mask(&mut self, m: &OpacityMask) { + /// `; (AI9_Mask) ,`. The art is in document coordinates, as the object's is. False, writing + /// nothing, when some of the art is left out for the object limit ([`Self::take_objects`]): a + /// mask without all its art would change what it shows. + fn mask(&mut self, m: &OpacityMask) -> bool { let outer = std::mem::take(&mut self.body); - let (state, clip) = (self.state, self.clip.take()); + let (state, gs, clip) = (self.state, self.gs, self.clip.take()); + let (charges, bound) = (self.charges(), self.bound.len()); (self.state, self.gs) = (Some((false, false)), Some(Xy::OPAQUE)); // The canvas draws the art's root even when it is hidden, and the reader puts a hidden object // in a group that doesn't draw it, so a hidden root is written shown. @@ -709,6 +829,13 @@ impl<'a> Writer<'a> { self.hidden -= 1; self.nest -= 1; let lines = std::mem::replace(&mut self.body, outer); + if self.left_out_since(charges) { + // The art isn't written, nor are the variable bindings of its objects. + self.give_back(charges, &lines); + self.bound.truncate(bound); + (self.state, self.gs, self.clip, self.block_end) = (state, gs, clip, usize::MAX); + return false; + } self.body.push_str("%_/Mask :\n%_X=\n"); hide(&lines, &mut self.body); self.body.push_str("%_X+\n%_(Art) ,\n"); @@ -718,6 +845,7 @@ impl<'a> Writer<'a> { self.body.push_str("%_; (AI9_Mask) ,\n"); // The dictionary ends with the state before it, except the transparency its art set last. (self.state, self.gs, self.clip, self.block_end) = (state, None, clip, usize::MAX); + true } /// Warn that art is cut to clipping object `first` when it isn't a path or compound path (a path @@ -739,12 +867,16 @@ impl<'a> Writer<'a> { if self.clip.is_some() && !children.is_empty() { self.warn(CLIPPING); } - self.line("u"); - self.clip_contents(children); - return self.end_group("U", xy); + if self.open_group("u") { + self.clip_contents(children); + self.end_group("U", xy); + } + return; } let at_q = (self.state, self.gs); - self.line("q"); + if !self.open_group("q") { + return; + } self.clipped(children); (self.state, self.gs) = at_q; self.end_group("Q", xy); @@ -753,7 +885,8 @@ impl<'a> Writer<'a> { /// The members of a clipping group or layer, the first one clipping the others: the others, /// then the first one as the clipping path (see [`Self::clip_group`]), its name after it. Fills /// and strokes the clipping path can't paint itself are paths of their own, the fills under the - /// others and the strokes over them. + /// others and the strokes over them. Without room for the clipping path, nothing is written + /// ([`Self::take_objects`]). fn clipped(&mut self, children: &[Arc]) { let Some((first, rest)) = children.split_first() else { return }; let Some((path, rule)) = clip_path(first) else { @@ -762,6 +895,12 @@ impl<'a> Writer<'a> { }; let items = self.items(first, path.to_bezpath().bounding_box()); let painted = self.plain_paint(&items); + // The clipping path goes after the others, so its objects are counted first. + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + let (fill, stroke) = painted.unwrap_or_default(); + if !self.take_objects(clip_object_count(&subpaths, fill, stroke)) { + return; + } if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { self.warn(ITEM_TRANSPARENCY); } @@ -779,12 +918,10 @@ impl<'a> Writer<'a> { self.node(c); } self.clip_paint_part(first, &path, rule, &strokes); - let (fill, stroke) = painted.unwrap_or_default(); self.state(first.locked, !first.visible); let start = self.body.len(); self.paint_state(fill, stroke, Xy::of(first).object(), rule); let op = paint_op(fill.is_some(), stroke.is_some()); - let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); let several = subpaths.len() > 1; if several { self.line("*u"); @@ -817,7 +954,9 @@ impl<'a> Writer<'a> { if let [one] = items { return self.item(path, rule, one, xy); } - self.line("u"); + if !self.open_group("u") { + return; + } for i in items { self.item(path, rule, i, Xy::PLAIN); } @@ -870,11 +1009,12 @@ impl<'a> Writer<'a> { (Some((fill, stroke)), _) => self.path_object(path, rule, fill, stroke, xy), (None, [one]) => self.item(path, rule, one, xy), (None, items) => { - self.line("u"); - for i in items { - self.item(path, rule, i, Xy::PLAIN); + if self.open_group("u") { + for i in items { + self.item(path, rule, i, Xy::PLAIN); + } + self.end_group("U", xy); } - self.end_group("U", xy); } } } @@ -898,7 +1038,8 @@ impl<'a> Writer<'a> { } /// A brushed stroke `st` of `path` as a group of its brush's art, of transparency `xy`. False - /// when it has no brush the document knows (or brushes nest too deep). + /// when it has no brush the document knows (or brushes nest too deep). Without room for the + /// group ([`Self::take_objects`]), the stroke is left out. fn brush_art(&mut self, path: &PathData, st: &StrokeLayer, xy: Xy) -> bool { if self.nest >= MAX_NEST { return false; @@ -908,8 +1049,10 @@ impl<'a> Writer<'a> { let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st); let state = self.state; + if !self.open_group("u") { + return true; + } self.nest += 1; - self.line("u"); for piece in &art { self.node(piece); } @@ -948,13 +1091,15 @@ impl<'a> Writer<'a> { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; return self.path_object(path, rule, Some(&flat), None, xy); } + let state = self.state; + if !self.open_group("u") { + return; + } self.wrote_pattern = true; let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region); self.nest += 1; - let state = self.state; - self.line("u"); for t in &tiles { self.node(t); } @@ -979,8 +1124,8 @@ impl<'a> Writer<'a> { } let both = fill.is_some() && stroke.is_some(); let own = if both { Xy::OPAQUE } else { xy.object() }; - if both { - self.line("u"); + if both && !self.open_group("u") { + return; } if fill.is_some() || stroke.is_none() { let region = self.cut(path, rule); @@ -1026,7 +1171,7 @@ impl<'a> Writer<'a> { /// points and painting operator (`*u` … `*U` around several). fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>, xy: Xy) { let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); - if subpaths.is_empty() { + if subpaths.is_empty() || !self.take_objects(object_count(subpaths.len())) { return; } self.paint_state(fill, stroke, xy, rule); @@ -1057,18 +1202,29 @@ impl<'a> Writer<'a> { fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; let doc = self.doc; - let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol { - None - } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { + let thread = doc.text_threads.iter().find(|th| th.contains(&n.id)); + // Type that can't be a stub is written as its outlines, which count their own objects. + if self.hidden > 0 || self.clip.is_some() || self.in_symbol || (thread.is_none() && !texts::holds(n, t)) { + return self.outlined_text(n, xy); + } + // The stub's objects ([`texts::stub_objects`], [`Self::take_objects`]); without room for + // them, the type is left out before its story goes into the text document. + let stub = texts::stub_objects(t); + if !self.take_objects(stub) { + return; + } + let placed = if let Some(thread) = thread { // A thread is checked for what it loses, in every frame, when its story is written. let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id)); placed.map(|p| (p, self.texts.take_lost())) - } else if texts::holds(n, t) { - self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t))) } else { - None + self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t))) + }; + let Some(((story, frame), lost)) = placed else { + // The text document can't hold it: the outlines count their own objects. + self.objects -= stub; + return self.outlined_text(n, xy); }; - let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) }; self.typed = true; if lost { self.warn(texts::TYPE_ATTRIBUTES); @@ -1088,8 +1244,10 @@ impl<'a> Writer<'a> { /// graphics as their symbols' art where the layout places them, as the PDF page draws them; /// the group has the type's transparency `xy`. fn outlined_text(&mut self, n: &Node, xy: Xy) { + if !self.open_group("u") { + return; + } self.warn(TYPE); - self.line("u"); let doc = self.doc; let resolved = match &n.kind { NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), @@ -1343,6 +1501,30 @@ fn paint_op(fill: bool, stroke: bool) -> char { } } +/// The objects the reader makes of a path object of `subpaths` subpaths with points: a path for +/// each, and a compound path holding them when there are several. +fn object_count(subpaths: usize) -> usize { + match subpaths { + 0 => 0, + 1 => 1, + n => n + 1, + } +} + +/// The objects the reader counts for clipping path `subpaths` (those with points) painted with +/// `fill` and `stroke` ([`Writer::clipped`]). Its last subpath carries a gradient fill's or +/// stroke's gradient, so the subpaths of a filled compound clipping path with a gradient paint +/// differently, and the reader reads their fills apart from it: a compound path of a copy of each, +/// which counts the copy and its points ([`object_count`] for the clipping path itself). +fn clip_object_count(subpaths: &[&SubPath], fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) -> usize { + let own = object_count(subpaths.len()); + let apart = subpaths.len() > 1 && fill.is_some() && (gradient(fill.map(|f| &f.paint)).is_some() || gradient(stroke.map(|s| &s.paint)).is_some()); + if !apart { + return own; + } + subpaths.iter().fold(own.saturating_add(1), |n, sp| n.saturating_add(1).saturating_add(sp.anchors.len())) +} + /// The subpaths of compound path `n`'s members, as one path. fn compound_path(n: &Node) -> PathData { let mut subpaths = vec![]; diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 898c91aa7..36c21c14e 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -107,6 +107,11 @@ impl Writer<'_> { if self.clip.as_ref().is_some_and(|c| c.subpaths.iter().all(|sp| sp.anchors.is_empty())) { return; } + // The image, and where it is cut, the clipping group and its clipping path. + let clipping = self.clip.as_ref().map_or(0, |c| 1 + super::object_count(c.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count())); + if !self.take_objects(1 + clipping) { + return; + } if deep { self.warn(IMAGE_DEPTH); } diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index b4966968e..b9d28253d 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -381,7 +381,8 @@ impl Writer<'_> { /// Write pattern `name`'s definition, once: `%AI3_BeginPattern: (name)`, `(name) llx lly urx /// ury` (the tile, y up), the tile's box (an unpainted path whose dictionary marks it, /// `AIPattern_Editor_Backing_Tile_Rect`) and the art on `%_` lines, `E`, `%AI3_EndPattern`. - /// False when it can't be ([`Self::pattern_writable`]). + /// False when it can't be ([`Self::pattern_writable`]), or when its box and art don't all fit + /// within the object limit ([`Self::take_objects`]). fn define_pattern(&mut self, name: &str) -> bool { if !self.pattern_writable(name) { return false; @@ -392,7 +393,9 @@ impl Writer<'_> { return true; } let doc = self.doc; - let Some(def) = doc.pattern(name) else { return false }; + // The tile's box is an object of the definition ([`Self::take_objects`]). + let (before, bound) = (self.charges(), self.bound.len()); + let Some(def) = doc.pattern(name).filter(|_| self.take_objects(1)) else { return false }; self.defining.push(name.to_string()); // The art is written in the pattern's space, y up, from the state a definition starts in. let saved = ( @@ -427,6 +430,12 @@ impl Writer<'_> { (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); + if self.left_out_since(before) { + self.give_back(before, &art); + self.bound.truncate(bound); + return false; + } + self.keep_since(before); let tile = Affine::FLIP_Y.transform_rect_bbox(def.tile); let quoted = ps::bytes(name.as_bytes()); let mut text = format!("%AI3_BeginPattern: {quoted}\n{quoted} "); diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index ace7e6b0f..8f52a0daa 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -504,7 +504,18 @@ impl Writer<'_> { /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or /// compound path's points painted with nothing, or with its gradient fill when its style has /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + /// The look's group and the object itself are counted first ([`Self::take_objects`]): without + /// room for them, nothing is written. pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { + let own = match &orig.kind { + NodeKind::Path { path, .. } => super::object_count(path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), + NodeKind::Compound { .. } => super::object_count(compound_path(orig).subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()), + NodeKind::Group { .. } => 1, + _ => 0, + }; + if !self.take_objects(1 + own) { + return; + } self.state(orig.locked, !orig.visible); let xy = Xy::of(orig); let was = self.in_look; @@ -533,6 +544,10 @@ impl Writer<'_> { let saved = std::mem::take(&mut self.body); self.hidden += 1; let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); + // A path's objects were kept for it above; it counts them itself. + if !matches!(orig.kind, NodeKind::Group { .. }) { + self.objects -= own; + } match &orig.kind { NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index ccec95d42..495bc0074 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -183,8 +183,9 @@ impl Writer<'_> { /// written from the top of the nesting whatever the instance that asks for it, so a definition /// is the same wherever its first instance lies. False when it can't be: the document doesn't /// have the symbol, it is being written (its art holds an instance of itself), it is reached - /// through more than [`MAX_NEST`] definitions being written, or the definitions written and - /// being written leave no place under [`MAX_SYMBOLS`]. + /// through more than [`MAX_NEST`] definitions being written, the definitions written and + /// being written leave no place under [`MAX_SYMBOLS`], or its art doesn't all fit within the + /// object limit ([`Self::take_objects`]). pub(super) fn define_symbol(&mut self, name: &str) -> bool { if self.defined_symbols.contains(name) { return true; @@ -204,6 +205,8 @@ impl Writer<'_> { self.warn(SYMBOL_OPTIONS); } self.defining_symbols.push(name.to_string()); + // The objects of this definition and of those it defines first. + let before = self.charges(); let mut used = vec![]; vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { @@ -215,9 +218,18 @@ impl Writer<'_> { } if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { self.defining_symbols.pop(); + self.keep_since(before); self.warn(SYMBOL_COUNT); return false; } + // The group that holds the art is an object of the definition ([`Self::take_objects`]); + // without room for it, or for all the art, there is no definition. + let (art_start, bound) = (self.charges(), self.bound.len()); + if !self.take_objects(1) { + self.defining_symbols.pop(); + self.keep_since(before); + return false; + } // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the // document at its own size. let origin = self.canvas_origin; @@ -261,7 +273,9 @@ impl Writer<'_> { let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n"); } else { // A group always writes its dictionary ([`Self::dictionary`]), which takes the entries. + // It counts itself, in the room kept for it above. self.root_entries = Some((root.id, entries)); + self.objects -= 1; self.node(root); } self.nest = nest; @@ -293,6 +307,13 @@ impl Writer<'_> { self.doc = page_doc; let art = std::mem::replace(&mut self.body, art); self.defining_symbols.pop(); + if self.left_out_since(art_start) { + self.give_back(art_start, &art); + self.bound.truncate(bound); + self.keep_since(before); + return false; + } + self.keep_since(before); let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); self.defined_symbols.insert(name.to_string()); self.symbol_defs.push(text); diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index e8ccda106..c7d95b992 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -1002,6 +1002,12 @@ fn clone_v(v: &V) -> V { } } +/// The objects the reader reads of [`stub`] for `t`: the text object, and a path for each subpath +/// of its frame's path. +pub(in crate::ai_editing) fn stub_objects(t: &TextObject) -> usize { + 1 + shape(t).map_or(0, |p| p.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count()) +} + /// The text object stub of story `story`, frame `frame`: `/AI11Text :`, `/FreeUndo`, `/FrameIndex`, /// `/StoryIndex`, for area type and type on a path the frame's path (`/Art :`, `X=`, the path in /// art space through `to_art`, `X+`, `; /ConfiningPath ,`), `/TextAntialiasing`, `;`. diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 8bc8a174c..21f8f3faa 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,9 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; +#[cfg(test)] +pub(crate) use ai::objects_read; +pub(crate) use ai::{MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 5537b5d06..7a0075ce5 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -40,11 +40,13 @@ pub(crate) use envelope::MAX_CHECKED; pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; +#[cfg(test)] +pub(crate) use read::objects_read; pub use read::{ BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, variables, }; -pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; +pub(crate) use read::{MAX_NODES as MAX_EDITING_OBJECTS, MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 104321918..fa5963df4 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -140,6 +140,7 @@ pub use document::{ pub(crate) use document::{slot_frame, slot_of}; pub(crate) use ops::read_art_style; pub use types::IMAGE_TOO_LARGE; +pub(crate) use types::MAX_NODES; use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; #[cfg(test)] pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; @@ -235,6 +236,14 @@ pub fn read(data: &[u8], large_canvas: bool) -> Result { r.finish() } +/// How many objects [`read`] counts against [`MAX_NODES`] in the editing data `data`. +#[cfg(test)] +pub(crate) fn objects_read(data: &[u8]) -> Result { + let mut r = Reader::with_names(data); + r.run()?; + Ok(r.nodes) +} + /// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private /// data): the program's header gives the author and the custom colors the editing data's header /// doesn't. diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 8b9607d18..8a781f8e7 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -9,8 +9,8 @@ pub(super) const MAX_SUBREADERS: usize = 32; /// Most operands on the stack. pub(super) const MAX_STACK: usize = 1 << 20; -/// Most objects read. -pub(super) const MAX_NODES: usize = 4_000_000; +/// Most objects read. The `.ai` editing data writer writes no more ([`crate::import::MAX_EDITING_OBJECTS`]). +pub(crate) const MAX_NODES: usize = 4_000_000; /// The most objects all the blends of one import may lay out to be checked (each twice) and made /// live; past it, blends keep their drawn steps. From 36eec28617cf955b6a3a7e5ceafc49a1986df25e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 04:40:23 -0800 Subject: [PATCH 47/82] MCP: open_file takes an importer plug-in to open the file with open_file passes `importer` (an importer plug-in's format id, plugin.) on to app.open. Headless, it becomes document.open's `importer`; in the app, app.open with `importer` runs file.openWith, so the file opens with that importer as File > Open With does, also when a built-in format reads its extension. --- crates/mcp/src/headless.rs | 9 +++++++-- crates/mcp/src/tests/fileio.rs | 16 ++++++++++++++++ crates/mcp/src/tools/definitions.rs | 10 ++++++++-- crates/mcp/src/tools/dispatch.rs | 6 +++++- crates/ui-egui/src/control.rs | 5 ++++- .../ui-egui/src/dialogs/tests_plugin_import.rs | 5 +++++ docs/control-protocol.md | 2 +- docs/mcp.md | 4 ++-- 8 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0a81bb127..05ea0fb4c 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -295,11 +295,16 @@ impl Headless { Ok(out) } - /// `app.open {path}`: any readable file as a new active document (`document.open`). + /// `app.open {path, importer?}`: any readable file as a new active document (`document.open`, + /// with the importer plug-in `importer` names). pub fn open(&mut self, p: &Value) -> Result { let path = s(p, "path").ok_or("missing `path`")?; + let mut params = json!({"path": path}); + if let Some(importer) = p.get("importer").filter(|v| !v.is_null()) { + params["importer"] = importer.clone(); + } let roots = self.roots.clone(); - file_access::confine(roots.as_ref(), || self.session.execute("document.open", &json!({"path": path}))).map_err(|e| e.to_string()) + file_access::confine(roots.as_ref(), || self.session.execute("document.open", ¶ms)).map_err(|e| e.to_string()) } /// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 58a6260be..6d934d48e 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -415,3 +415,19 @@ fn document_color_mode_puts_rgb_greys_on_the_black_plate() { let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}})); assert!(bad.is_error, "{bad:?}"); } + +/// `open_file`'s `importer` reaches `document.open`: an SVG opens with the SVG reader without it, +/// and naming an importer that isn't installed is an error that names it. +#[test] +fn open_file_passes_the_importer_on() { + let tools = tool_definitions(); + let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); + assert!(open["inputSchema"]["properties"].get("importer").is_some(), "{open}"); + let svg = tmp("importer.svg"); + std::fs::write(&svg, r#""#).unwrap(); + let mut h = Headless::new(); + assert_eq!(text(&call_tool(&mut h, "open_file", &json!({"path": svg})))["format"], "svg"); + let r = call_tool(&mut h, "open_file", &json!({"path": svg, "importer": "plugin.test.mcp.absent"})); + assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("test.mcp.absent"), "{r:?}"); + assert_eq!(h.session.documents().len(), 1); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 105dca346..5af8a0c27 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -357,10 +357,16 @@ pub fn tool_definitions() -> Vec { "open_file", "Open file", &format!( - "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read).", + "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads.", OPEN_EXTS.join(", .") ), - obj(json!({"path": string("File path")}), &["path"]), + obj( + json!({ + "path": string("File path"), + "importer": string("An importer plug-in's format id, plugin. (see run_command document.formats), which must list the file's extension (default: the built-in format that reads the file, else the first importer by id that lists its extension)"), + }), + &["path"], + ), false, ), tool( diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index 57167439f..cab82b3b6 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -191,7 +191,11 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result screenshot(b, a), "open_file" => { let path = absolute_path(b, req_str(a, "path")?)?; - j(b.call("app.open", json!({"path": path}))?) + let mut p = json!({"path": path}); + if let Some(importer) = a.get("importer").filter(|v| !v.is_null()) { + p["importer"] = importer.clone(); + } + j(b.call("app.open", p)?) } "save_file" => { let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?}); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fccfbedab..a7f154b52 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -443,7 +443,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques None => ok(with_data(json!({"width": img.width, "height": img.height}), Some(&png))), } } - "app.open" => wrap(app.run("file.open", json!({"path": s("path")}))), + "app.open" => match p.get("importer").filter(|v| !v.is_null()) { + Some(importer) => wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer}))), + None => wrap(app.run("file.open", json!({"path": s("path")}))), + }, "app.save" | "app.export" => { let mut p = if p.is_object() { p.clone() } else { json!({}) }; // `wait: true`: the file is written before the reply, whatever Background Save and diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 7d2e7b9a1..9b3a45b70 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -68,6 +68,11 @@ fn open_with_lists_importers_and_opens_a_file_with_one() { assert_eq!(r["format"], "plugin.test.ui.svgreader"); assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer"); assert_eq!(app.session.documents().len(), 2); + // `app.open {importer}` (MCP's open_file) opens as Open With does. + let (req, _rx) = crate::control::ControlRequest::new("app.open", json!({"path": svg, "importer": "plugin.test.ui.svgreader"})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") }; + assert_eq!(r["result"]["format"], "plugin.test.ui.svgreader", "{r}"); + assert_eq!(app.session.documents().len(), 3); // An importer is not a filter: Object › Plug-ins' command says where it is. let e = app.run("plugin.dialog", json!({"id": "test.ui.svgreader"})).unwrap_err(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e8408cd5..1d8d4f699 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/mcp.md b/docs/mcp.md index ce1b5885e..5c3aaabe9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -443,7 +443,7 @@ opens the picker through `run_command` with the remote backend. | `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?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/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). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/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). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | 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). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, 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. | @@ -6092,7 +6092,7 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: -"plugin.", params?}` (and `open_file`) opens a file whose extension no built-in format reads, by its content or its +"plugin.", params?}` (and `open_file {path, importer?}`) opens a file whose extension no built-in format reads, by its content or its extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). `importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such as `svg`. The importer is given at most 128 MiB of the file; its objects become a new document whose artboard is From 58f5789e9ab17891029948b07e6715812f0d06b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:07:08 -0800 Subject: [PATCH 48/82] Plug-ins: Open With passes an importer its parameters and adds the file to Open Recent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Open With asks for the parameters of an importer that declares any, at their defaults, once the file is picked (dialog `pluginImport`), and opens the file with the values set. `file.openWith`, `app.open` and MCP's `open_file` take `params` and pass them to the importer; `app.open` without them runs the importer on their defaults, with no dialog. A file opened with Open With is added to Open Recent, as File › Open adds the files it opens. --- crates/mcp/src/headless.rs | 10 ++-- crates/mcp/src/tests/fileio.rs | 27 ++++++++++ crates/mcp/src/tools/definitions.rs | 3 +- crates/mcp/src/tools/dispatch.rs | 6 ++- crates/ui-egui/src/control.rs | 6 ++- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/plugin.rs | 32 ++++++++++- .../src/dialogs/tests_plugin_import.rs | 54 ++++++++++++++++++- crates/ui-egui/src/io/open.rs | 30 +++++++---- docs/control-protocol.md | 6 ++- docs/mcp.md | 10 ++-- docs/plugins.md | 6 ++- 12 files changed, 164 insertions(+), 27 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 05ea0fb4c..8633fd568 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -295,13 +295,15 @@ impl Headless { Ok(out) } - /// `app.open {path, importer?}`: any readable file as a new active document (`document.open`, - /// with the importer plug-in `importer` names). + /// `app.open {path, importer?, params?}`: any readable file as a new active document + /// (`document.open`, with the importer plug-in `importer` names and its parameters `params`). pub fn open(&mut self, p: &Value) -> Result { let path = s(p, "path").ok_or("missing `path`")?; let mut params = json!({"path": path}); - if let Some(importer) = p.get("importer").filter(|v| !v.is_null()) { - params["importer"] = importer.clone(); + for k in ["importer", "params"] { + if let Some(v) = p.get(k).filter(|v| !v.is_null()) { + params[k] = v.clone(); + } } let roots = self.roots.clone(); file_access::confine(roots.as_ref(), || self.session.execute("document.open", ¶ms)).map_err(|e| e.to_string()) diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 6d934d48e..00ff3a2d7 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -431,3 +431,30 @@ fn open_file_passes_the_importer_on() { assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("test.mcp.absent"), "{r:?}"); assert_eq!(h.session.documents().len(), 1); } + +/// `open_file` sends `app.open` the importer's `params` with the importer. +#[test] +fn open_file_passes_the_importer_s_params_on() { + struct Recording(Vec<(String, Value)>); + impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.0.push((method.to_string(), params)); + Ok(json!({})) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "recording".into() + } + } + let tools = tool_definitions(); + let open = tools.iter().find(|t| t["name"] == "open_file").unwrap(); + assert_eq!(open["inputSchema"]["properties"]["params"]["type"], "object", "{open}"); + let mut b = Recording(vec![]); + let args = json!({"path": "/in/a.tup", "importer": "plugin.test.mcp.params", "params": {"density": 7}}); + assert!(!call_tool(&mut b, "open_file", &args).is_error); + let [(method, params)] = &b.0[..] else { panic!("one call: {:?}", b.0) }; + assert_eq!(method, "app.open"); + assert_eq!(params, &args); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 5af8a0c27..616e32aae 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -357,13 +357,14 @@ pub fn tool_definitions() -> Vec { "open_file", "Open file", &format!( - "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads.", + "Open a file as a new, active document: .{} (see run_command document.formats). Templates (.ait, native templates) open as a new untitled document. The reply's `warnings` say what didn't come in as it was (an EPS shown as its preview image says why its PostScript couldn't be read). A file of an extension no built-in format reads opens with the installed importer plug-in that lists it; `importer` names the importer to use (File › Open With), also for an extension a built-in format reads, and `params` gives that importer's parameters.", OPEN_EXTS.join(", .") ), obj( json!({ "path": string("File path"), "importer": string("An importer plug-in's format id, plugin. (see run_command document.formats), which must list the file's extension (default: the built-in format that reads the file, else the first importer by id that lists its extension)"), + "params": {"type": "object", "description": "The importer plug-in's parameters (see run_command plugin.list; default: the defaults its manifest declares)"}, }), &["path"], ), diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index cab82b3b6..eabd73e3e 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -192,8 +192,10 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { let path = absolute_path(b, req_str(a, "path")?)?; let mut p = json!({"path": path}); - if let Some(importer) = a.get("importer").filter(|v| !v.is_null()) { - p["importer"] = importer.clone(); + for k in ["importer", "params"] { + if let Some(v) = a.get(k).filter(|v| !v.is_null()) { + p[k] = v.clone(); + } } j(b.call("app.open", p)?) } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a7f154b52..203fd46a3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -444,7 +444,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } } "app.open" => match p.get("importer").filter(|v| !v.is_null()) { - Some(importer) => wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer}))), + // The importer's parameters, else their defaults: automation gets no dialog asking for them. + Some(importer) => { + let params = p.get("params").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({})); + wrap(app.run("file.openWith", json!({"path": s("path"), "importer": importer, "params": params}))) + } None => wrap(app.run("file.open", json!({"path": s("path")}))), }, "app.save" | "app.export" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1e301a7ca..bc4616acf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -390,6 +390,7 @@ registry! { PrintPresets: [print_presets::KIND] => print_presets::SPEC, Plugin: [plugin::KIND] => plugin::SPEC, PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC, + PluginImport: [plugin::IMPORT_KIND] => plugin::IMPORT_SPEC, ScriptCheck: [script_check::KIND] => script_check::SPEC, VectorHalftone: [halftone::KIND] => halftone::SPEC, SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC, diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 8096f1e58..a5736c47e 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -9,7 +9,8 @@ //! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › //! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its //! Export button writes the file. Importers open the files File › Open finds no built-in format -//! for, and File › Open With lists them, to open a file with one of them by name. +//! for, and File › Open With lists them, to open a file with one of them by name; one with +//! parameters opens dialog `pluginImport` once the file is picked, and its Open button opens it. use std::sync::Arc; @@ -211,3 +212,32 @@ fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result app.ui.dialog = None; crate::io::export(app, format.as_deref(), path, &p) } + +/// The dialog kind of an importer's parameters, after Open With picked the file. +pub const IMPORT_KIND: &str = "pluginImport"; + +/// Headed by the plug-in's name, as it is; Open opens the file. +pub(super) const IMPORT_SPEC: DialogSpec = DialogSpec { + heading: |d| d.str("__label"), + body: export_body, + confirm: import_confirm, + ok: Some("Open"), + max_width: Some(420.0), + ..DialogSpec::FORM +}; + +/// Open With with importer `plugin`, which has parameters: its fields at their defaults, and +/// `open`, Open With's own parameters (`path`, `importer`), kept for Open. +pub fn open_import(app: &mut VectorcraftApp, plugin: &Plugin, open: Value) { + let mut fields = fields_for(plugin.manifest()); + fields.insert("__open".into(), open); + app.ui.dialog = Some(Dialog { kind: IMPORT_KIND.into(), fields }); +} + +/// Opens the file with Open With's parameters and the plug-in's, as `params`. +fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = d.fields.get("__open").cloned().filter(Value::is_object).ok_or("no file to open")?; + p["params"] = form::params(d); + app.ui.dialog = None; + crate::io::open_with(app, &p) +} diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index 9b3a45b70..a8ce15ded 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -6,8 +6,9 @@ use serde_json::json; use vectorcraft_engine::Session; -use vectorcraft_plugins::wat as tpl; +use vectorcraft_plugins::wat::{self as tpl, Given}; +use super::tests_export::{frame, kind, set}; use super::*; use crate::Services; use crate::menus::Item; @@ -64,10 +65,12 @@ fn open_with_lists_importers_and_opens_a_file_with_one() { let svg = svg.to_string_lossy().into_owned(); crate::io::open_path(&mut app, &svg).unwrap(); assert_eq!(artboard(&app), (0.0, 0.0, 50.0, 40.0), "File › Open: the built-in SVG reader"); + app.ui.recent_files.clear(); let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.svgreader", "path": svg})).unwrap(); assert_eq!(r["format"], "plugin.test.ui.svgreader"); assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0), "Open With: the importer"); assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&svg), "Open With adds the file to Open Recent"); // `app.open {importer}` (MCP's open_file) opens as Open With does. let (req, _rx) = crate::control::ControlRequest::new("app.open", json!({"path": svg, "importer": "plugin.test.ui.svgreader"})); let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &req) else { panic!("not done") }; @@ -83,6 +86,55 @@ fn open_with_lists_importers_and_opens_a_file_with_one() { let _ = std::fs::remove_dir_all(&dir); } +/// Open With asks for an importer's parameters, at their defaults, once the file is picked, and +/// opens the file with the values set; given `params`, it asks nothing, and `app.open` without +/// them runs the importer on their defaults. +#[test] +fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { + let dir = tmp_dir("params"); + let mut app = app(); + let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; + // RECT when the parameters hold density 7; the file, which isn't JSON, otherwise. + install(&mut app, &tpl::when_given(&tpl::importer("test.ui.params", r#"["tup"]"#, params), Given::Params, r#""density":7.0"#, RECT)); + let file = dir.join("a.tup"); + std::fs::write(&file, "not json").unwrap(); + let file = file.to_string_lossy().into_owned(); + + app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file})).unwrap(); + assert_eq!(kind(&app), Some(plugin::IMPORT_KIND)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.params".to_string(), Some(&json!(4.0)))); + assert!(app.session.documents().is_empty(), "nothing opened before Open"); + frame(&mut app); + set(&mut app, "density", json!(7)); + confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(artboard(&app), (10.0, 20.0, 110.0, 70.0)); + assert_eq!(app.ui.recent_files, std::slice::from_ref(&file)); + + let r = app.run("file.openWith", json!({"importer": "plugin.test.ui.params", "path": file, "params": {"density": 7}})).unwrap(); + assert_eq!(r["format"], "plugin.test.ui.params"); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.documents().len(), 2); + + // `app.open` (MCP's open_file) passes `params` on, and asks nothing without them. + let control = |app: &mut VectorcraftApp, p: serde_json::Value| { + let (req, _rx) = crate::control::ControlRequest::new("app.open", p); + let crate::control::Outcome::Done(r) = crate::control::handle(app, &egui::Context::default(), &req) else { panic!("not done") }; + r + }; + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.params", "params": {"density": 7}})); + assert_eq!(r["result"]["format"], "plugin.test.ui.params", "{r}"); + assert_eq!(app.session.documents().len(), 3); + let r = control(&mut app, json!({"path": file, "importer": "plugin.test.ui.params"})); + assert!(r.get("error").is_some(), "the defaults (density 4) don't open the file: {r}"); + assert_ne!(kind(&app), Some(plugin::IMPORT_KIND)); + assert_eq!(app.session.documents().len(), 3); + app.run("plugin.remove", json!({"id": "test.ui.params"})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + /// File › Open reads a file of an extension only an importer lists, and refuses one past the /// importer's input limit before reading it, as Open With does. #[test] diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index bb605de7b..94b41ea7d 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -260,9 +260,11 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { /// File › Open With: opens the file `p` names (`path`), else one picked among the files with the /// extensions the importer plug-in `importer` lists, with that importer, also when a built-in -/// format reads the extension (`document.open {importer}`); a failure is reported as File › Open -/// reports it. The web's file picker can't carry the importer: there File › Open's picker opens, -/// and the file opens as File › Open opens it. +/// format reads the extension (`document.open {importer, params}`), and adds it to Open Recent; a +/// failure is reported as File › Open reports it. An importer with parameters, given no `params`, +/// asks for them first in dialog `pluginImport`, at their defaults. The web's file picker can't +/// carry the importer: there File › Open's picker opens, and the file opens as File › Open opens +/// it. pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { let importer = p.get("importer").and_then(Value::as_str).ok_or("missing `importer` (see document.formats)")?; let plugin = vectorcraft_plugins::import::named(importer).map_err(|e| e.to_string())?; @@ -279,12 +281,22 @@ pub fn open_with(app: &mut VectorcraftApp, p: &Value) -> Result { pick_open(app, &FilePick { filters, folder: open_dialog_folder(app), ..Default::default() })? } }; - let opened = vectorcraft_engine::file_access::check_read(&path) - .and_then(|()| check_import_size(&path, true)) - .and_then(|()| read(app, &path)) - .and_then(|bytes| { - open_document(app, &path, &bytes, Some(path.clone()), &json!({ "importer": vectorcraft_plugins::export::format_id(plugin.id()) })) - }); + let importer = vectorcraft_plugins::export::format_id(plugin.id()); + let given = p.get("params").filter(|v| !v.is_null()); + let opened = vectorcraft_engine::file_access::check_read(&path).and_then(|()| check_import_size(&path, true)).and_then(|()| { + if given.is_none() && !plugin.manifest().params.is_empty() { + dialogs::plugin::open_import(app, &plugin, json!({ "path": path, "importer": importer })); + return Ok(Value::Null); + } + let mut o = json!({ "importer": importer }); + if let Some(params) = given { + o["params"] = params.clone(); + } + let bytes = read(app, &path)?; + let r = open_document(app, &path, &bytes, Some(path.clone()), &o)?; + note_recent(app, &path); + Ok(r) + }); if let Err(e) = &opened { report_open_error(app, &fileio::file_name(&path), e); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1d8d4f699..ce944a78b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | @@ -213,7 +213,9 @@ after it), `__plugin` (the plug-in id) and `preview`. It previews `plugin.run` o `ui.dialog.confirm` keeps the result as one undo step and `ui.dialog.cancel` rolls it back. Live-effect plug-ins open the `effect` dialog (`effect.dialog {effect: "plugin."}`). An exporter plug-in with parameters opens the `pluginExport` dialog once Export As has picked the file: one field per parameter, `__plugin`, and `__export` (the -export's own parameters); `ui.dialog.confirm` writes the file. `ui.installPlugin {path?}` installs a `.wasm` (without +export's own parameters); `ui.dialog.confirm` writes the file. An importer plug-in with parameters opens the +`pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open` +(`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without a path it asks for one); see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for diff --git a/docs/mcp.md b/docs/mcp.md index 5c3aaabe9..8a5e83f18 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -443,7 +443,7 @@ opens the picker through `run_command` with the remote backend. | `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?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | -| `open_file` | `{path, importer?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/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). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does). `run_command document.formats` lists the formats. | +| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/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). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | 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). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, 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. | @@ -6092,14 +6092,16 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: -"plugin.", params?}` (and `open_file {path, importer?}`) opens a file whose extension no built-in format reads, by its content or its +"plugin.", params?}` (and `open_file {path, importer?, params?}`) opens a file whose extension no built-in format reads, by its content or its extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). `importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such as `svg`. The importer is given at most 128 MiB of the file; its objects become a new document whose artboard is their bounds, Save then asks for a name, and `warnings` holds the importer's warnings. `plugin.run` of an importer is an error that names `document.open`. In the app, File › Open With lists the installed importers, and -`file.openWith {importer, path?}` opens `path`, or a file picked among the importer's extensions, with one (on the web -it opens File › Open's picker). A failing plug-in returns an error and leaves the document as it was (an export writes +`file.openWith {importer, path?, params?}` opens `path`, or a file picked among the importer's extensions, with one (on +the web it opens File › Open's picker) and adds it to Open Recent; an importer with parameters, given no `params`, +first asks for them in the `pluginImport` dialog. `params` holds the importer's parameters (their defaults when +omitted). A failing plug-in returns an error and leaves the document as it was (an export writes no file, and an import opens no document). 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` runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it. diff --git a/docs/plugins.md b/docs/plugins.md index 4b29b5ef2..c9d40853f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -60,8 +60,10 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the -installed importers by name (`file.openWith {"importer", "path"?}`), and each opens a file picked among its extensions -with that importer (on the web, File › Open's picker opens instead). **File › Open** of a `.wasm` file installs +installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its +extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer +with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open +button opens the file; File › Open runs an importer with its parameters' defaults. **File › Open** of a `.wasm` file installs a plug-in too (on the web this is how plug-ins get in). A heavy filter asks before it is installed, and runs in the background ([below](#heavy-filters)). A plug-in that lists permissions asks for them before it is installed ([below](#permissions)). From d65207dc620de7c9474905524257569c4eb99a67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:35:23 -0800 Subject: [PATCH 49/82] =?UTF-8?q?Plug-ins:=20only=20File=20=E2=80=BA=20Ope?= =?UTF-8?q?n=20lists=20importers'=20files,=20and=20imported=20art=20at=20t?= =?UTF-8?q?he=20canvas=20edge=20keeps=20an=20editable=20artboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New from Template, Load Character/Paragraph/All Styles, Recolor's Other Library… and the library panels' Other Library… read files with the built-in readers, so their open dialogs now use builtin_open_filters(), which leaves out the extensions only importer plug-ins read. File › Open keeps open_filters() with the importers' files. The artboard of a document an importer opens is now kept within ±4,000,000 pt. Art at the edge (a zero-width path at x = 4,000,000, or a stroke past the edge) gave an artboard beyond it, and every later edit of the document was rejected. --- crates/engine/src/cmd/fileio.rs | 6 ++-- crates/engine/src/cmd/fileio/formats.rs | 22 +++++++++++--- crates/engine/src/cmd/fileio/importer.rs | 21 ++++++++++---- crates/engine/tests/plugin_import.rs | 15 ++++++++++ crates/ui-egui/src/dialogs/recolor.rs | 4 +-- .../src/dialogs/tests_plugin_import.rs | 29 +++++++++++++++++++ crates/ui-egui/src/io/open.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 2 +- crates/ui-egui/src/panels/text_styles.rs | 2 +- docs/plugins.md | 7 +++-- 10 files changed, 89 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 9903ba4cb..52d54ce19 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -130,9 +130,9 @@ pub(crate) use export_names::{default_name, merge, unique_file_names, with_black use export_names::{distinct_names_by, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ - DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, - file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, open_filters, place_filters, - plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, + DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, + export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, + open_filters, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 49f8af812..40acda270 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -693,7 +693,7 @@ pub fn open_extensions() -> &'static [&'static str] { exts } -/// Open-dialog filters: "All readable files" first, then one per readable format, then one per +/// File › Open's filters: "All readable files" first, then one per readable format, then one per /// importer plug-in (under its name), then swatch libraries (which open in the library panel), /// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked /// without running them) and plug-ins (installed). @@ -701,7 +701,20 @@ pub fn open_filters() -> impl Iterator impl Iterator { + std::iter::once(("All readable files", OPEN_EXTS)).chain(format_filters()).chain(other_open_filters()) +} + +/// The open-dialog filters after the document formats: swatch libraries, Libraries panel +/// libraries, presets, shortcut sets, scripts and plug-ins. +fn other_open_filters() -> impl Iterator { + std::iter::once(("Swatch libraries", crate::cmd::swatchlib::LIBRARY_EXTS)) .chain(std::iter::once(("Libraries", crate::cmd::library::LIBRARY_EXTS))) .chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS))) .chain(std::iter::once(("PDF presets", crate::cmd::pdfcmds::PRESET_EXTS))) @@ -715,12 +728,13 @@ pub fn open_filters() -> impl Iterator impl Iterator { - std::iter::once((library_label, library_exts)).chain(open_filters()) + std::iter::once((library_label, library_exts)).chain(builtin_open_filters()) } /// File → Place dialog filters: "All placeable files", then one per placeable format, then text, diff --git a/crates/engine/src/cmd/fileio/importer.rs b/crates/engine/src/cmd/fileio/importer.rs index 0a341c079..9fc667c4a 100644 --- a/crates/engine/src/cmd/fileio/importer.rs +++ b/crates/engine/src/cmd/fileio/importer.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::Document; +use vectorcraft_doc::{Document, MAX_COORD}; use vectorcraft_geom::Rect; use vectorcraft_plugins::{Plugin, export, import, objects}; @@ -101,15 +101,24 @@ pub(super) fn load(name: &str, bytes: &[u8], plugin: &Plugin, p: &Value) -> Resu let ids = objects::apply_output(&mut doc, &[], imported.objects, layer, &admit).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; if let (Some(bounds), Some(board)) = (doc.bounds_of(&ids, true), doc.artboards.first_mut()) { - board.rect = at_least_a_point(bounds); + board.rect = artboard_rect(bounds); } doc.title = file_name(name); Ok(Loaded { doc, format, warnings: imported.warnings, restored: false, converted: false, preview_only: false, editing_data: false }) } -/// `r` at least 1 pt wide and high, about its center: the artboard of a single line or point. -fn at_least_a_point(r: Rect) -> Rect { - let grow = |lo: f64, hi: f64| if hi - lo >= 1.0 { (lo, hi) } else { ((lo + hi) / 2.0 - 0.5, (lo + hi) / 2.0 + 0.5) }; - let ((x0, x1), (y0, y1)) = (grow(r.x0, r.x1), grow(r.y0, r.y1)); +/// The artboard of art with bounds `r`: `r` within ±[`MAX_COORD`], at least 1 pt wide and high +/// about its center (the artboard of a single line or point). A side that would pass the edge +/// moves inward, so art at the edge keeps an artboard the document's edits accept. +fn artboard_rect(r: Rect) -> Rect { + let side = |lo: f64, hi: f64| { + let (lo, hi) = (lo.max(-MAX_COORD), hi.min(MAX_COORD)); + if hi - lo >= 1.0 { + return (lo, hi); + } + let mid = ((lo + hi) / 2.0).clamp(-MAX_COORD + 0.5, MAX_COORD - 0.5); + (mid - 0.5, mid + 0.5) + }; + let ((x0, x1), (y0, y1)) = (side(r.x0, r.x1), side(r.y0, r.y1)); Rect::new(x0, y0, x1, y1) } diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index f56684172..f5cfea80b 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -109,6 +109,21 @@ fn a_file_an_importer_reads_opens_as_editable_art() { open(&mut s, "Line.tso", LINE.as_bytes(), json!({})).unwrap(); assert_eq!(artboard(&s), (0.0, 4.5, 50.0, 5.5)); + // Art at the edge of the canvas keeps its artboard within ±4,000,000 pt, so the document can + // still be edited: a vertical line at x = 4,000,000 (its 1 pt moves inward) and a stroked line + // along y = -4,000,000 (its stroke passes the edge). + let edge = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[4000000,0]},{"p":[4000000,50]}]}]}}]}"#; + open(&mut s, "Edge.tso", edge.as_bytes(), json!({})).unwrap(); + assert_eq!(artboard(&s), (3_999_999.0, 0.0, 4_000_000.0, 50.0)); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap(); + let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#; + open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap(); + let (x0, y0, x1, y1) = artboard(&s); + assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); + // From a path: the document keeps no path, so Save asks for a name. let path = dir.join("Shapes.tso"); std::fs::write(&path, RECT).unwrap(); diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 5efa3ef6e..6f696f430 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -604,8 +604,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut // Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it. if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() { let r = crate::picks::in_dialog(app, d, |app, d| { - let pick = - crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::open_filters()), ..Default::default() }; + let filters = crate::FilePick::filters(vectorcraft_engine::cmd::fileio::builtin_open_filters()); + let pick = crate::FilePick { filters, ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) }; let r = app.run("swatch.library.load", json!({ "path": path }))?; d.fields.insert("limitTo".into(), r["library"].clone()); diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index a8ce15ded..eab73422e 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -135,6 +135,35 @@ fn open_with_asks_for_an_importer_s_parameters_and_passes_them_on() { let _ = std::fs::remove_dir_all(&dir); } +/// File › Open's dialog lists an importer's files; the open dialogs whose files only the built-in +/// readers read (New from Template, Load Styles, the library panels' Other Library…) don't. +#[test] +fn only_file_open_s_dialog_lists_an_importer_s_files() { + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.filters", r#"["tuf"]"#, "{}"), tpl::ECHO, "", 1)); + let asked: std::rc::Rc>> = Default::default(); + let seen = asked.clone(); + app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| { + seen.borrow_mut().push(pick.clone()); + None + })); + let lists = |filters: &[(String, Vec)]| filters.iter().any(|(_, exts)| exts.iter().any(|e| e == "tuf")); + let shown = |app: &mut VectorcraftApp, cmd: &str| { + let _ = app.run(cmd, json!({})); + let pick = asked.borrow_mut().pop().unwrap_or_else(|| panic!("{cmd} showed no open dialog")); + lists(&pick.filters) + }; + assert!(shown(&mut app, "file.open"), "File › Open"); + for cmd in ["file.newFromTemplate", "ui.loadCharStyles", "ui.loadParaStyles", "ui.loadAllStyles"] { + assert!(!shown(&mut app, cmd), "{cmd}"); + } + for kind in ["swatches", "graphicStyles", "symbols"] { + let filters = crate::FilePick::filters(crate::panels::library_panel::library_pick_filters(kind)); + assert!(!lists(&filters), "{kind}"); + } + app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap(); +} + /// File › Open reads a file of an extension only an importer lists, and refuses one past the /// importer's input limit before reading it, as Open With does. #[test] diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 94b41ea7d..fc0aa8481 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -461,7 +461,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Resu Some(p) => p, None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), None => { - let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); + let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::builtin_open_filters())); pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? } }; diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 4607edbf9..f7ee6020a 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -573,7 +573,7 @@ pub(crate) fn library_pick_filters(kind: &str) -> Vec<(&'static str, &'static [& match kind { super::swatches::SwatchLibraries::KIND => fileio::library_open_filters("Swatch libraries", swatchlib::LIBRARY_EXTS).collect(), super::graphic_styles::GraphicStyleLibraries::KIND => fileio::library_open_filters("Graphic Style Libraries", &[STYLES_EXT]).collect(), - _ => fileio::open_filters().collect(), + _ => fileio::builtin_open_filters().collect(), } } diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index eb2908400..c0a260fc9 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -421,7 +421,7 @@ pub(crate) fn load(app: &mut VectorcraftApp, load: &str, path: Option) - let path = match path { Some(path) => path, None => { - let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::open_filters()), ..Default::default() }; + let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::builtin_open_filters()), ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(Value::Null) }; path } diff --git a/docs/plugins.md b/docs/plugins.md index c9d40853f..7329488df 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -47,8 +47,8 @@ reads the file, by its content or by its extension, and the importer lists the f one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` only this way). A damaged file of a built-in format reports that format's error, and the file is not handed to an -importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high); -the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are +importer. The importer's objects become a new document whose artboard is their bounds (at least 1 pt wide and high, +and within ±4,000,000 pt); the document keeps no path, so Save asks for a name. File › Place reads only the built-in formats. These rules are VectorCraft's own. In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload @@ -59,7 +59,8 @@ parameters it runs at once. Parameters too many for the window's height scroll i heading and buttons in view. Exporters are formats of **File › Export › Export As**, where Use Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › -Open's "All readable files" and under a filter of their own, named after the importer; **File › Open With** lists the +Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New +from Template and Load Styles, list only the files the built-in formats read); **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open From 01c89d4bc5a3bde9ec2b45040faed50687e0a548 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:35:27 -0800 Subject: [PATCH 50/82] JavaScript: a small heap limit no longer leaves a realm without built-ins Installing the built-ins is not bounded by the heap limit: Math and JSON no longer reserve heap while they install, so a realm made with a tiny heap_bytes gets every built-in and runs once the limit is raised. If installing fails, the realm keeps the reason and every run ends with an internal error instead of running against missing built-ins. The json2.js test skips only when the built-in check returns false, and fails on any other result. The parser's progress line says the app does not run scripts yet. --- crates/js/src/builtins.rs | 13 +++++++++---- crates/js/src/builtins/json.rs | 1 - crates/js/src/builtins/math.rs | 3 +-- crates/js/src/builtins_tests.rs | 25 +++++++++++++++++++++++++ crates/js/src/interp.rs | 3 +++ crates/js/src/realm.rs | 6 +++++- crates/js/tests/json2.rs | 10 +++++++--- docs/target-app-parity.md | 2 +- 8 files changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 4fc1adc87..0e437c961 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -53,12 +53,17 @@ fn constructor(interp: &mut Interp, name: &'static str, ctor: ObjId, proto: ObjI data(interp, global, name, Value::Object(ctor), HIDDEN) } -/// Creates the built-ins and the global object's properties. +/// Creates the built-ins and the global object's properties. Installing is not bounded by +/// [`crate::Limits`]; if it fails, the failure is kept in `interp.install_error` and every run of +/// the realm ends with it. pub(crate) fn install(interp: &mut Interp) { if let Err(e) = try_install(interp) { - // Installing touches only objects it has just made, so this does not happen; a realm - // that somehow lacks a built-in reports it when a script uses it. - let _ = e; + let reason = match e { + Abrupt::Throw(v) => interp.describe(&v), + Abrupt::Limit(kind) => format!("limit reached: {kind:?}"), + Abrupt::Internal(message) => message, + }; + interp.install_error = Some(format!("the built-ins could not be installed: {reason}")); } } diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 8061379ce..b84d9dc7b 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -16,7 +16,6 @@ use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; use crate::value::{Str, Value, number_to_str}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { - interp.reserve_heap(OBJECT_BYTES)?; let proto = interp.intrinsics.object_proto; let json = interp.heap.alloc_object(Object::new(Class::Json, Some(proto))); method(interp, json, "parse", parse, 2)?; diff --git a/crates/js/src/builtins/math.rs b/crates/js/src/builtins/math.rs index 3ad9421ed..08fe96a04 100644 --- a/crates/js/src/builtins/math.rs +++ b/crates/js/src/builtins/math.rs @@ -2,12 +2,11 @@ //! cases the section lists for NaN, signed zeros and infinities. use super::{arg, data, method}; -use crate::heap::{Class, NativeFn, OBJECT_BYTES, Object}; +use crate::heap::{Class, NativeFn, Object}; use crate::interp::{Interp, JsResult}; use crate::value::Value; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { - interp.reserve_heap(OBJECT_BYTES)?; let proto = interp.intrinsics.object_proto; let math = interp.heap.alloc_object(Object::new(Class::Math, Some(proto))); let constants = [ diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index 4a4c2aea4..7261054b7 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -356,6 +356,31 @@ fn define_properties_checks_the_heap_before_each_new_property() { assert!(realm.interp().heap.bytes <= heap_bytes, "{} > {heap_bytes}", realm.interp().heap.bytes); } +#[test] +fn a_heap_limit_smaller_than_the_built_ins_still_installs_every_built_in() { + // The heap limit bounds what scripts make, not the realm's own built-ins: a realm made with a + // tiny limit refuses runs with the heap limit, and has every built-in once the limit is raised. + let mut realm = small(Limits { heap_bytes: 1, ..Limits::default() }); + assert_eq!(limit(&mut realm, "[]"), LimitKind::Heap); + realm.set_limits(Limits::default()); + assert_eq!( + realm.run("typeof Math.max + typeof JSON.stringify + typeof parseInt + typeof escape + typeof decodeURIComponent"), + Ok(JsValue::String("functionfunctionfunctionfunctionfunction".into())) + ); +} + +#[test] +fn a_realm_whose_built_ins_failed_to_install_runs_nothing() { + let mut realm = Realm::new(); + realm.interp().install_error = Some("the built-ins could not be installed: test".into()); + match realm.run("var ran = true; 1") { + Err(RunError { kind: RunErrorKind::Internal, message, .. }) => assert_eq!(message, "the built-ins could not be installed: test"), + other => panic!("{other:?}"), + } + realm.interp().install_error = None; + assert_eq!(realm.run("typeof ran"), Ok(JsValue::String("undefined".into()))); +} + #[test] fn number_parsing_counts_its_copy_toward_the_heap_limit() { // `s` is 1 Mi digits, so parsing it in base 10 copies 1 MiB, which does not fit in the diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 7168f9dad..32ab99275 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -170,6 +170,8 @@ pub(crate) struct Interp { /// charged to `heap.bytes` and added back after each collection, and a native call's /// buffers are released when it returns. pub(crate) held_bytes: usize, + /// Why installing the built-ins failed; a realm with this set runs no script. + pub(crate) install_error: Option, } /// The least heap size a collection waits for. @@ -307,6 +309,7 @@ impl Interp { random: random_seed(), join_stack: Vec::new(), held_bytes: 0, + install_error: None, }; crate::builtins::install(&mut interp); interp.gc_threshold = interp.next_threshold(); diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index f95d57d17..18f39d96b 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -170,9 +170,13 @@ impl Realm { } /// Runs `source` as a script in the global environment and returns its completion value (the - /// value of the last expression statement that ran). + /// value of the last expression statement that ran). A realm whose built-ins could not be + /// installed runs nothing and gives an internal error. pub fn run(&mut self, source: &str) -> Result { let interp = &mut self.interp; + if let Some(message) = &interp.install_error { + return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); + } interp.fuel = interp.limits.fuel; interp.source_units = 0; interp.throw_site = None; diff --git a/crates/js/tests/json2.rs b/crates/js/tests/json2.rs index 8692e9b30..c998e55a9 100644 --- a/crates/js/tests/json2.rs +++ b/crates/js/tests/json2.rs @@ -72,9 +72,13 @@ fn json2_polyfill_round_trips_with_the_built_in_json() { }; let source = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); let mut realm = Realm::new(); - if realm.run(NEEDS) != Ok(JsValue::Bool(true)) { - println!("json2: SKIPPED (the interpreter lacks Date, String.prototype.replace or RegExp, which json2.js uses)"); - return; + match realm.run(NEEDS) { + Ok(JsValue::Bool(true)) => {} + Ok(JsValue::Bool(false)) => { + println!("json2: SKIPPED (the interpreter lacks Date, String.prototype.replace or RegExp, which json2.js uses)"); + return; + } + other => panic!("json2: the built-in check failed: {other:?}"), } match realm.run(&format!("{BEFORE}\n{source}\n{ROUND_TRIP}")) { Ok(JsValue::String(s)) if s == "ok" => println!("json2: the polyfill and the built-in JSON agree on every sample"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 32585a163..193066828 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -662,7 +662,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. + - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). The app does not run scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next entry lists. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 3,362 of 3,387 runs pass (4 of the others expect the completion values of later editions, and 21 call `String` or `Number` methods). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects and visit only the indices that exist, so `new Array(4294967295).join("")` ends at once; `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 4,480 of 4,556 runs pass (16 of the failing tests call `String`, `Number` or `Boolean` methods, and 22 expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit to compare with the built-in `JSON`; that test runs once `Date`, `String.prototype.replace` and `RegExp` exist. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: From d8afed1562b34ecc224351d10ec215b2bbdba4ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:52:39 -0800 Subject: [PATCH 51/82] 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